diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..a156124 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,426 @@ +# Agent Guidelines for Go Project Template + +This document provides guidelines for AI coding agents working in this Go project. Follow these conventions to maintain consistency with the existing codebase. + +## Build, Lint, and Test Commands + +### Building +```bash +make build # Build the application binary +make run-server # Build and run HTTP server +make run-worker # Build and run background worker +make run-migrate # Build and run database migrations +``` + +### Testing +```bash +make test # Run all tests with coverage +make test-with-db # Start databases, run tests, stop databases (full cycle) +make test-db-up # Start PostgreSQL (5433) and MySQL (3307) test databases +make test-db-down # Stop test databases + +# Run a single test or test file +go test -v ./internal/user/repository -run TestPostgreSQLUserRepository_Create +go test -v ./internal/user/usecase -run TestUserUseCase + +# Run tests for a specific package +go test -v ./internal/user/repository +go test -v ./internal/user/usecase + +# Run tests with race detection +go test -v -race ./... + +# View coverage in browser +make test-coverage +``` + +### Linting +```bash +make lint # Run golangci-lint with auto-fix +golangci-lint run -v --fix # Direct linter command +``` + +### Other Commands +```bash +make clean # Remove build artifacts and coverage files +make deps # Download and tidy dependencies +make help # Show all available make targets +``` + +## Project Architecture + +This is a **Clean Architecture** project following **Domain-Driven Design** principles with a modular domain structure. + +### Directory Structure +- `cmd/app/` - Application entry point (CLI with server/worker/migrate commands) +- `internal/` + - `{domain}/` - Domain modules (e.g., `user/`, `outbox/`) + - `domain/` - Entities, value objects, domain errors + - `usecase/` - UseCase interfaces and business logic + - `repository/` - Data persistence (MySQL and PostgreSQL implementations) + - `http/` - HTTP handlers and DTOs + - `dto/` - Request/response DTOs, mappers + - `app/` - Dependency injection container + - `errors/` - Standardized domain errors + - `httputil/` - HTTP utilities (JSON responses, error handling) + - `database/` - Database connection and transaction management + - `config/` - Configuration management + - `validation/` - Custom validation rules + - `testutil/` - Test helper utilities + +### Layer Responsibilities + +**Domain Layer** (`domain/`) +- Define entities with pure business logic (no JSON tags) +- Define domain-specific errors by wrapping standard errors from `internal/errors` +- Example: `ErrUserNotFound = errors.Wrap(errors.ErrNotFound, "user not found")` + +**Use Case Layer** (`usecase/`) +- 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()` + +**Repository Layer** (`repository/`) +- Implement separate repositories for MySQL and PostgreSQL +- Transform infrastructure errors to domain errors (e.g., `sql.ErrNoRows` → `domain.ErrUserNotFound`) +- Use `database.GetTx(ctx, r.db)` to support transactions +- Check for unique constraint violations and return appropriate domain errors + +**HTTP Layer** (`http/`) +- Define request/response DTOs with JSON tags (keep domain models pure) +- 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 + +## Code Style Guidelines + +### Imports +Follow this import grouping order (enforced by `goimports`): +1. Standard library packages +2. External packages (third-party) +3. Local project packages (prefixed with `github.com/allisson/go-project-template`) + +Example: +```go +import ( + "context" + "database/sql" + "strings" + + "github.com/google/uuid" + validation "github.com/jellydator/validation" + + "github.com/allisson/go-project-template/internal/database" + "github.com/allisson/go-project-template/internal/errors" + "github.com/allisson/go-project-template/internal/user/domain" +) +``` + +**Important:** When renaming imports, use descriptive aliases: +- `apperrors` for `internal/errors` +- `appValidation` for `internal/validation` +- `outboxDomain` for `internal/outbox/domain` + +### Formatting +- **Line length**: Max 110 characters (enforced by `golines`) +- **Indentation**: Use tabs (tab-len: 4) +- **Comments**: Not shortened by golines +- Use `goimports` and `golines` for consistent formatting +- Run `make lint` before committing + +### Naming Conventions + +**Packages** +- Use lowercase, single-word names when possible +- Avoid underscores or mixed caps +- Examples: `domain`, `usecase`, `repository`, `http` + +**Types** +- Use PascalCase for exported types +- Use camelCase for unexported types +- Suffix interfaces with meaningful names, not just "Interface" + - Good: `UserRepository`, `TxManager`, `UseCase` + - Bad: `UserRepositoryInterface`, `IUserRepository` + +**Functions/Methods** +- Use PascalCase for exported functions +- Use camelCase for unexported functions +- Prefix boolean functions with `is`, `has`, `can`, or `should` + - Examples: `isPostgreSQLUniqueViolation`, `hasUpperCase` + +**Variables** +- Use camelCase for short-lived variables +- Use descriptive names for longer-lived variables +- Avoid single-letter names except for: `i`, `j`, `k` (loops), `r` (receiver), `w` (http.ResponseWriter), `ctx` (context) + +**Constants** +- Use PascalCase for exported constants +- Use camelCase for unexported constants + +### Types and Interfaces + +**UUIDs** +- Use `uuid.UUID` type from `github.com/google/uuid` +- Generate IDs with `uuid.Must(uuid.NewV7())` (time-ordered UUIDs) +- PostgreSQL: Store as native `UUID` type +- MySQL: Store as `BINARY(16)` with marshal/unmarshal + +**Domain Models** +- Keep domain models pure (no JSON tags) +- Use DTOs for API serialization +- Example: + ```go + // Domain model (no JSON tags) + type User struct { + ID uuid.UUID + Name string + Email string + Password string + CreatedAt time.Time + UpdatedAt time.Time + } + ``` + +**DTOs** +- Add JSON tags for API contracts +- Implement `Validate()` method using `jellydator/validation` +- Create mapper functions to convert between DTOs and domain models + +### Error Handling + +**Standard Domain Errors** (from `internal/errors`) +- `ErrNotFound` - Resource doesn't exist (404) +- `ErrConflict` - Duplicate or conflicting data (409) +- `ErrInvalidInput` - Validation failures (422) +- `ErrUnauthorized` - Authentication required (401) +- `ErrForbidden` - Permission denied (403) + +**Domain-Specific Errors** +Always wrap standard errors with domain context: +```go +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") +) +``` + +**Error Flow** +1. **Repository Layer**: Transform infrastructure errors to domain errors + ```go + if errors.Is(err, sql.ErrNoRows) { + return nil, domain.ErrUserNotFound + } + ``` + +2. **Use Case Layer**: Return domain errors directly (don't wrap again) + ```go + user, err := uc.userRepo.GetByID(ctx, id) + if err != nil { + return nil, err // Pass through domain error + } + ``` + +3. **HTTP Handler Layer**: Use `httputil.HandleError()` for automatic mapping + ```go + if err != nil { + httputil.HandleError(w, err, h.logger) + return + } + ``` + +**Error Checking** +- Use `errors.Is()` to check for specific errors +- Use `errors.As()` to extract error types +- Never compare errors with `==` (except for `sql.ErrNoRows` from stdlib) + +### Validation + +Use `github.com/jellydator/validation` for input validation: + +```go +import ( + validation "github.com/jellydator/validation" + appValidation "github.com/allisson/go-project-template/internal/validation" +) + +func (r *RegisterUserRequest) Validate() error { + err := validation.ValidateStruct(r, + validation.Field(&r.Name, + validation.Required.Error("name is required"), + appValidation.NotBlank, + validation.Length(1, 255), + ), + validation.Field(&r.Email, + validation.Required.Error("email is required"), + appValidation.Email, + ), + validation.Field(&r.Password, + validation.Required.Error("password is required"), + appValidation.PasswordStrength{ + MinLength: 8, + RequireUpper: true, + RequireLower: true, + RequireNumber: true, + RequireSpecial: true, + }, + ), + ) + return appValidation.WrapValidationError(err) +} +``` + +**Custom Validation Rules** (in `internal/validation`): +- `Email` - Email format validation +- `NotBlank` - Not empty after trimming +- `NoWhitespace` - No leading/trailing whitespace +- `PasswordStrength` - Configurable password requirements + +### Database Patterns + +**Repository Pattern** +- Implement separate repositories for MySQL and PostgreSQL +- Use `database.GetTx(ctx, r.db)` to get querier (works with and without transactions) +- Use numbered placeholders for PostgreSQL (`$1, $2`) and `?` for MySQL + +**Transaction Management** +```go +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 +}) +``` + +**Unique Constraint Violations** +Check for database-specific error patterns: +- PostgreSQL: `strings.Contains(err.Error(), "duplicate key")` +- MySQL: `strings.Contains(err.Error(), "duplicate entry")` + +### Testing + +**Integration Testing Philosophy** +- Use real databases (PostgreSQL port 5433, MySQL port 3307) instead of mocks +- Tests verify actual SQL queries and database behavior +- Start test databases with `make test-db-up` before running tests + +**Test Structure** +```go +func TestPostgreSQLUserRepository_Create(t *testing.T) { + db := testutil.SetupPostgresDB(t) // Connect and run migrations + defer testutil.TeardownDB(t, db) // Clean up connection + defer testutil.CleanupPostgresDB(t, db) // Clean up test data + + repo := NewPostgreSQLUserRepository(db) + ctx := context.Background() + + user := &domain.User{ + ID: uuid.Must(uuid.NewV7()), + Name: "John Doe", + Email: "john@example.com", + Password: "hashed_password", + } + + err := repo.Create(ctx, user) + assert.NoError(t, err) +} +``` + +**Test Naming** +- Format: `Test{Type}_{Method}` or `Test{Type}_{Method}_{Scenario}` +- Examples: `TestPostgreSQLUserRepository_Create`, `TestUserUseCase_RegisterUser_DuplicateEmail` + +### Comments and Documentation + +**Package Comments** +Every package should have a doc comment: +```go +// Package usecase implements the user business logic and orchestrates user domain operations. +package usecase +``` + +**Exported Types** +Document all exported types, functions, and constants: +```go +// User represents a user in the system +type User struct { ... } + +// RegisterUser registers a new user and creates a user.created event +func (uc *UserUseCase) RegisterUser(ctx context.Context, input RegisterUserInput) (*domain.User, error) { +``` + +**Implementation Comments** +Add comments for complex logic: +```go +// Hash the password using Argon2id +hashedPassword, err := uc.passwordHasher.Hash([]byte(input.Password)) +``` + +## Adding New Domains + +When adding a new domain (e.g., `product`): + +1. **Create the domain structure**: + ``` + internal/product/ + ├── domain/ + │ └── product.go # Entity + domain errors + ├── usecase/ + │ └── product_usecase.go # UseCase interface + implementation + ├── repository/ + │ ├── mysql_product_repository.go + │ └── postgresql_product_repository.go + └── http/ + ├── dto/ + │ ├── request.go # With Validate() method + │ ├── response.go # With JSON tags + │ └── mapper.go # DTO ↔ domain conversions + └── product_handler.go # Uses httputil functions + ``` + +2. **Define domain errors** in `domain/product.go`: + ```go + var ( + ErrProductNotFound = errors.Wrap(errors.ErrNotFound, "product not found") + ErrInvalidPrice = errors.Wrap(errors.ErrInvalidInput, "invalid price") + ) + ``` + +3. **Register in DI container** (`internal/app/di.go`): + - Add repository and use case fields + - Add getter methods with `sync.Once` initialization + - Select repository based on `c.config.DBDriver` + +4. **Wire HTTP handlers** in `internal/http/server.go` + +5. **Write migrations** for both PostgreSQL and MySQL + +## Common Pitfalls to Avoid + +❌ **Don't** add JSON tags to domain models +✅ **Do** use DTOs for API serialization + +❌ **Don't** wrap domain errors multiple times +✅ **Do** return domain errors directly from use cases + +❌ **Don't** use `sql.ErrNoRows` in use cases +✅ **Do** transform to domain errors in repositories + +❌ **Don't** use single repository for both databases +✅ **Do** implement separate MySQL and PostgreSQL repositories + +❌ **Don't** use auto-incrementing integer IDs +✅ **Do** use UUIDv7 with `uuid.Must(uuid.NewV7())` + +❌ **Don't** create mocks for repositories +✅ **Do** use real test databases (ports 5433 and 3307) + +❌ **Don't** depend on concrete use case implementations +✅ **Do** depend on UseCase interfaces in handlers and DI container diff --git a/README.md b/README.md index 866485b..f9237fb 100644 --- a/README.md +++ b/README.md @@ -1,329 +1,144 @@ -# Go Project Template - -A production-ready Go project template following Clean Architecture and Domain-Driven Design principles, optimized for building scalable applications with PostgreSQL or MySQL. - -## Features - -- **Modular Domain Architecture** - Domain-based code organization for scalability -- **Clean Architecture** - Separation of concerns with domain, repository, use case, and presentation layers -- **Standardized Error Handling** - Domain errors with proper HTTP status code mapping -- **Dependency Injection Container** - Centralized component wiring with lazy initialization and clean resource management -- **Multiple Database Support** - PostgreSQL and MySQL with dedicated repository implementations using `database/sql` -- **Database Migrations** - Separate migrations for PostgreSQL and MySQL using golang-migrate -- **UUIDv7 Primary Keys** - Time-ordered, sortable UUIDs for globally unique identifiers -- **Transaction Management** - TxManager interface for handling database transactions -- **Transactional Outbox Pattern** - Event-driven architecture with guaranteed delivery -- **HTTP Server** - Standard library HTTP server with middleware for logging and panic recovery -- **Worker Process** - Background worker for processing outbox events -- **CLI Interface** - urfave/cli for running server, migrations, and worker -- **Health Checks** - Kubernetes-compatible readiness and liveness endpoints -- **Structured Logging** - JSON logs using slog -- **Configuration** - Environment variable based configuration with go-env -- **Input Validation** - Advanced validation with jellydator/validation library including password strength, email format, and custom rules -- **Password Hashing** - Secure password hashing with Argon2id via go-pwdhash -- **Docker Support** - Multi-stage Dockerfile for minimal container size -- **Integration Testing** - Real database tests using Docker Compose instead of mocks -- **CI/CD** - GitHub Actions workflow with PostgreSQL and MySQL for comprehensive testing -- **Comprehensive Makefile** - Easy development and deployment commands - -## Project Structure +# 🚀 Go Project Template -``` -go-project-template/ -├── cmd/ -│ └── 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 -│ │ ├── database.go -│ │ └── txmanager.go -│ ├── errors/ # Standardized domain errors -│ │ └── errors.go -│ ├── http/ # HTTP server and shared infrastructure -│ │ ├── middleware.go -│ │ ├── response.go -│ │ └── server.go -│ ├── httputil/ # HTTP utility functions -│ │ └── response.go # JSON responses and error mapping -│ ├── outbox/ # Outbox domain module -│ │ ├── domain/ # Outbox entities -│ │ │ └── outbox_event.go -│ │ └── repository/ # Outbox data access -│ │ ├── mysql_outbox_repository.go -│ │ └── postgresql_outbox_repository.go -│ ├── user/ # User domain module -│ │ ├── domain/ # User entities and domain errors -│ │ │ └── user.go -│ │ ├── http/ # User HTTP handlers -│ │ │ ├── dto/ # Request/response DTOs -│ │ │ │ ├── request.go -│ │ │ │ ├── response.go -│ │ │ │ └── mapper.go -│ │ │ └── user_handler.go -│ │ ├── repository/ # User data access -│ │ │ ├── mysql_user_repository.go -│ │ │ └── postgresql_user_repository.go -│ │ └── usecase/ # User business logic -│ │ └── user_usecase.go -│ ├── validation/ # Custom validation rules -│ │ ├── rules.go -│ │ └── rules_test.go -│ ├── testutil/ # Test utilities -│ │ └── database.go # Database test helpers -│ └── worker/ # Background workers -│ └── event_worker.go -├── migrations/ -│ ├── mysql/ # MySQL migrations -│ └── postgresql/ # PostgreSQL migrations -├── .github/ -│ └── workflows/ -│ └── ci.yml # CI workflow with PostgreSQL & MySQL -├── docker-compose.test.yml # Test database configuration -├── Dockerfile -├── Makefile -├── go.mod -└── go.sum -``` +> A production-ready Go project template following Clean Architecture and Domain-Driven Design principles, optimized for building scalable applications with PostgreSQL or MySQL. -### Domain Module Structure +[![CI](https://github.com/allisson/go-project-template/workflows/CI/badge.svg)](https://github.com/allisson/go-project-template/actions) +[![Go Report Card](https://goreportcard.com/badge/github.com/allisson/go-project-template)](https://goreportcard.com/report/github.com/allisson/go-project-template) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -The project follows a modular domain architecture where each business domain is organized in its own directory with clear separation of concerns: +## ✨ Features -- **`domain/`** - Contains entities, value objects, domain types, and domain-specific errors -- **`usecase/`** - Defines UseCase interfaces and implements business logic and orchestration -- **`repository/`** - Handles data persistence and retrieval, transforms infrastructure errors to domain errors -- **`http/`** - Contains HTTP handlers and DTOs (Data Transfer Objects) - - **`dto/`** - Request/response DTOs and mappers (API contracts) +- 🏗️ **Clean Architecture** - Clear separation of concerns with domain, repository, use case, and presentation layers +- 📦 **Modular Domain Structure** - Easy to add new domains without affecting existing code +- 🔌 **Dependency Injection** - Centralized component wiring with lazy initialization +- 🗄️ **Multi-Database Support** - PostgreSQL and MySQL with dedicated repository implementations +- 🔄 **Database Migrations** - Separate migrations for PostgreSQL and MySQL +- 🆔 **UUIDv7 Primary Keys** - Time-ordered, globally unique identifiers +- 💼 **Transaction Management** - Built-in support for database transactions +- 📬 **Transactional Outbox Pattern** - Event-driven architecture with guaranteed delivery +- ⚠️ **Standardized Error Handling** - Domain errors with proper HTTP status code mapping +- ✅ **Input Validation** - Advanced validation with custom rules (email, password strength, etc.) +- 🔒 **Password Hashing** - Secure Argon2id password hashing +- 🧪 **Integration Testing** - Real database tests instead of mocks +- 🐳 **Docker Support** - Multi-stage Dockerfile and Docker Compose setup +- 🚦 **CI/CD Ready** - GitHub Actions workflow with comprehensive testing +- 📊 **Structured Logging** - JSON logs using slog +- 🛠️ **Comprehensive Makefile** - Easy development and deployment commands -### Shared Utilities +## 📚 Documentation -- **`app/`** - Dependency injection container for assembling application components -- **`errors/`** - Standardized domain errors for expressing business intent -- **`httputil/`** - Shared HTTP utility functions including error mapping and JSON responses -- **`config/`** - Application-wide configuration -- **`database/`** - Database connection and transaction management -- **`testutil/`** - Test helper utilities for database setup and cleanup -- **`worker/`** - Background processing infrastructure +- 📖 [Getting Started](docs/getting-started.md) - Installation and setup guide +- 🏗️ [Architecture](docs/architecture.md) - Architectural patterns and design principles +- 🛠️ [Development](docs/development.md) - Development workflow and coding standards +- 🧪 [Testing](docs/testing.md) - Testing strategies and best practices +- ⚠️ [Error Handling](docs/error-handling.md) - Error handling system guide +- ➕ [Adding Domains](docs/adding-domains.md) - Step-by-step guide to add new domains -This structure makes it easy to add new domains (e.g., `internal/product/`, `internal/order/`) without affecting existing modules. +## 🚀 Quick Start -## Prerequisites +### Prerequisites -- Go 1.25 or higher -- PostgreSQL 12+ or MySQL 8.0+ (for development) -- Docker and Docker Compose (for testing and optional development) -- Make (optional, for convenience commands) +- Go 1.25+ +- PostgreSQL 12+ or MySQL 8.0+ +- Docker and Docker Compose (optional) -## Quick Start - -### 1. Clone the repository +### Installation ```bash +# Clone the repository git clone https://github.com/allisson/go-project-template.git cd go-project-template -``` - -### 2. Customize the module path - -After cloning, you need to update the import paths to match your project: - -**Option 1: Using find and sed (Linux/macOS)** -```bash -# Replace with your actual module path -NEW_MODULE="github.com/yourname/yourproject" - -# Update go.mod -sed -i "s|github.com/allisson/go-project-template|$NEW_MODULE|g" go.mod - -# Update all Go files -find . -type f -name "*.go" -exec sed -i "s|github.com/allisson/go-project-template|$NEW_MODULE|g" {} + -``` - -**Option 2: Using PowerShell (Windows)** -```powershell -# Replace with your actual module path -$NEW_MODULE = "github.com/yourname/yourproject" - -# Update go.mod -(Get-Content go.mod) -replace 'github.com/allisson/go-project-template', $NEW_MODULE | Set-Content go.mod -# Update all Go files -Get-ChildItem -Recurse -Filter *.go | ForEach-Object { - (Get-Content $_.FullName) -replace 'github.com/allisson/go-project-template', $NEW_MODULE | Set-Content $_.FullName -} -``` - -**Option 3: Manually** -1. Update the module name in `go.mod` -2. Search and replace `github.com/allisson/go-project-template` with your module path in all `.go` files - -After updating, verify the changes and tidy dependencies: -```bash -go mod tidy -``` - -**Important:** Also update the `.golangci.yml` file to match your new module path: - -```yaml -formatters: - settings: - goimports: - local-prefixes: - - github.com/yourname/yourproject # Update this line -``` - -This ensures the linter correctly groups your local imports. - -### UUIDv7 Primary Keys - -The project uses **UUIDv7** for all primary keys instead of auto-incrementing integers. UUIDv7 provides several advantages: - -**Benefits:** -- **Time-ordered**: UUIDs include timestamp information, maintaining temporal ordering -- **Globally unique**: No collision risk across distributed systems or databases -- **Database friendly**: Better index performance than random UUIDs (v4) due to sequential nature -- **Scalability**: No need for centralized ID generation or coordination -- **Merge-friendly**: Databases can be merged without ID conflicts +# Install dependencies +go mod download -**Implementation:** +# Start a database (using Docker) +make dev-postgres # or make dev-mysql -All ID fields use `uuid.UUID` type from `github.com/google/uuid`: +# Run migrations +make run-migrate -```go -import "github.com/google/uuid" - -type User struct { - ID uuid.UUID `db:"id" json:"id"` - Name string `db:"name"` - Email string `db:"email"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` -} +# Start the server +make run-server ``` -IDs are generated in the application code using `uuid.NewV7()`: +The server will be available at http://localhost:8080 -```go -user := &domain.User{ - ID: uuid.Must(uuid.NewV7()), - Name: input.Name, - Email: input.Email, - Password: hashedPassword, -} -``` +For detailed setup instructions, see the [Getting Started Guide](docs/getting-started.md). -**Database Storage:** -- **PostgreSQL**: `UUID` type (native support) -- **MySQL**: `BINARY(16)` type (16-byte storage) - -**Migration Example (PostgreSQL):** -```sql -CREATE TABLE users ( - id UUID PRIMARY KEY, - name VARCHAR(255) NOT NULL, - email VARCHAR(255) UNIQUE NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -); -``` +## 📖 Project Structure -**Migration Example (MySQL):** -```sql -CREATE TABLE users ( - id BINARY(16) PRIMARY KEY, - name VARCHAR(255) NOT NULL, - email VARCHAR(255) UNIQUE NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ``` - -### 3. Install dependencies - -```bash -go mod download +go-project-template/ +├── cmd/app/ # Application entry point +├── internal/ +│ ├── app/ # Dependency injection container +│ ├── config/ # Configuration management +│ ├── database/ # Database connection and transactions +│ ├── errors/ # Standardized domain errors +│ ├── http/ # HTTP server infrastructure +│ ├── httputil/ # HTTP utilities (JSON responses, error mapping) +│ ├── validation/ # Custom validation rules +│ ├── testutil/ # Test utilities +│ ├── worker/ # Background workers +│ ├── user/ # User domain module +│ │ ├── domain/ # User entities and domain errors +│ │ ├── usecase/ # User business logic +│ │ ├── repository/ # User data access +│ │ └── http/ # User HTTP handlers and DTOs +│ └── outbox/ # Outbox domain module +├── migrations/ +│ ├── postgresql/ # PostgreSQL migrations +│ └── mysql/ # MySQL migrations +├── docs/ # Documentation +├── Dockerfile +├── Makefile +└── docker-compose.test.yml ``` -### 4. Configure environment variables +Learn more about the architecture in the [Architecture Guide](docs/architecture.md). -The application automatically loads environment variables from a `.env` file. Create a `.env` file in your project root (or any parent directory): +## 🧪 Testing ```bash -# Database configuration -DB_DRIVER=postgres # or mysql -DB_CONNECTION_STRING=postgres://user:password@localhost:5432/mydb?sslmode=disable -DB_MAX_OPEN_CONNECTIONS=25 -DB_MAX_IDLE_CONNECTIONS=5 -DB_CONN_MAX_LIFETIME=5 - -# Server configuration -SERVER_HOST=0.0.0.0 -SERVER_PORT=8080 - -# Logging -LOG_LEVEL=info - -# Worker configuration -WORKER_INTERVAL=5 -WORKER_BATCH_SIZE=10 -WORKER_MAX_RETRIES=3 -WORKER_RETRY_INTERVAL=1 -``` - -**Note:** The application searches for the `.env` file recursively from the current working directory up to the root directory. This allows you to run the application from any subdirectory and it will still find your `.env` file. - -Alternatively, you can export environment variables directly without a `.env` file. - -### 5. Start a database (using Docker) +# Start test databases +make test-db-up -**PostgreSQL:** -```bash -make dev-postgres -``` +# Run all tests +make test -**MySQL:** -```bash -make dev-mysql -``` +# Run tests with coverage +make test-coverage -### 6. Run database migrations +# Stop test databases +make test-db-down -```bash -make run-migrate +# Or run everything in one command +make test-with-db ``` -### 7. Start the HTTP server +The project uses real PostgreSQL and MySQL databases for testing instead of mocks. See the [Testing Guide](docs/testing.md) for details. -```bash -make run-server -``` +## 🛠️ Development -The server will be available at http://localhost:8080 - -### 8. Start the worker (in another terminal) +### Build Commands ```bash -make run-worker +make build # Build the application +make run-server # Run HTTP server +make run-worker # Run background worker +make run-migrate # Run database migrations +make lint # Run linter with auto-fix +make clean # Clean build artifacts ``` -## Usage - -### HTTP Endpoints +### API Endpoints #### Health Check ```bash curl http://localhost:8080/health ``` -#### Readiness Check -```bash -curl http://localhost:8080/ready -``` - #### Register User ```bash curl -X POST http://localhost:8080/api/users \ @@ -335,992 +150,140 @@ curl -X POST http://localhost:8080/api/users \ }' ``` -**Password Requirements:** -- Minimum 8 characters -- At least one uppercase letter -- At least one lowercase letter -- At least one number -- At least one special character - -**Validation Errors:** - -If validation fails, you'll receive a 422 Unprocessable Entity response with details: - -```json -{ - "error": "invalid_input", - "message": "email: must be a valid email address; password: password must contain at least one uppercase letter." -} -``` - -### CLI Commands +For more development workflows, see the [Development Guide](docs/development.md). -The binary supports three commands via urfave/cli: +## 🔑 Key Concepts -#### Start HTTP Server -```bash -./bin/app server -``` - -#### Run Database Migrations -```bash -./bin/app migrate -``` - -#### Run Event Worker -```bash -./bin/app worker -``` - -## Error Handling - -The project implements a standardized error handling system that expresses business intent rather than exposing infrastructure details. - -### Domain Error Architecture - -**Standard Domain Errors** (`internal/errors/errors.go`) - -The project defines standard domain errors that can be used across all modules: - -```go -var ( - ErrNotFound = errors.New("not found") // 404 Not Found - ErrConflict = errors.New("conflict") // 409 Conflict - ErrInvalidInput = errors.New("invalid input") // 422 Unprocessable Entity - ErrUnauthorized = errors.New("unauthorized") // 401 Unauthorized - ErrForbidden = errors.New("forbidden") // 403 Forbidden -) -``` - -**Domain-Specific Errors** (`internal/user/domain/user.go`) - -Each domain defines its own specific errors that wrap the standard errors: - -```go -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") -) -``` +### Clean Architecture Layers -### Error Flow Through Layers +- **Domain Layer** 🎯 - Business entities and rules (pure, no external dependencies) +- **Repository Layer** 💾 - Data persistence (separate MySQL and PostgreSQL implementations) +- **Use Case Layer** 💼 - Business logic and orchestration +- **Presentation Layer** 🌐 - HTTP handlers and DTOs +- **Utility Layer** 🛠️ - Shared utilities (error handling, validation, HTTP helpers) -**1. Repository Layer** - Transforms infrastructure errors to domain errors: +### Error Handling System -```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 // Infrastructure → Domain - } - return nil, apperrors.Wrap(err, "failed to get user by id") - } - return &user, nil -} -``` +The project uses a standardized error handling system: -**2. Use Case Layer** - Returns domain errors directly: +- **Standard Errors**: `ErrNotFound`, `ErrConflict`, `ErrInvalidInput`, `ErrUnauthorized`, `ErrForbidden` +- **Domain Errors**: Wrap standard errors with domain-specific context +- **Automatic HTTP Mapping**: Domain errors automatically map to appropriate HTTP status codes +Example: ```go -func (uc *UserUseCase) RegisterUser(ctx context.Context, input RegisterUserInput) (*domain.User, error) { - if strings.TrimSpace(input.Email) == "" { - return nil, domain.ErrEmailRequired // Domain error - } - - if err := uc.userRepo.Create(ctx, user); err != nil { - return nil, err // Pass through domain errors - } - return user, nil -} -``` - -**3. HTTP Handler Layer** - Maps domain errors to HTTP responses: +// Define domain error +var ErrUserNotFound = errors.Wrap(errors.ErrNotFound, "user not found") -```go -func (h *UserHandler) RegisterUser(w http.ResponseWriter, r *http.Request) { - user, err := h.userUseCase.RegisterUser(r.Context(), input) - if err != nil { - httputil.HandleError(w, err, h.logger) // Auto-maps to HTTP status - return - } - httputil.MakeJSONResponse(w, http.StatusCreated, response) +// Use in repository +if errors.Is(err, sql.ErrNoRows) { + return nil, domain.ErrUserNotFound // Maps to 404 Not Found } ``` -### HTTP Error Responses - -The `httputil.HandleError` function automatically maps domain errors to appropriate HTTP status codes: - -| Domain Error | HTTP Status | Error Code | Example Response | -|--------------|-------------|------------|------------------| -| `ErrNotFound` | 404 | `not_found` | `{"error":"not_found","message":"The requested resource was not found"}` | -| `ErrConflict` | 409 | `conflict` | `{"error":"conflict","message":"A conflict occurred with existing data"}` | -| `ErrInvalidInput` | 422 | `invalid_input` | `{"error":"invalid_input","message":"invalid email format"}` | -| `ErrUnauthorized` | 401 | `unauthorized` | `{"error":"unauthorized","message":"Authentication is required"}` | -| `ErrForbidden` | 403 | `forbidden` | `{"error":"forbidden","message":"You don't have permission"}` | -| Unknown | 500 | `internal_error` | `{"error":"internal_error","message":"An internal error occurred"}` | - -### Benefits - -1. **No Infrastructure Leaks** - Database errors are never exposed to API clients -2. **Business Intent** - Errors express domain concepts (`ErrUserNotFound` vs `sql.ErrNoRows`) -3. **Consistent HTTP Mapping** - Same domain error always maps to same HTTP status -4. **Type-Safe** - Use `errors.Is()` to check for specific error types -5. **Structured Responses** - All errors return consistent JSON format -6. **Centralized Logging** - All errors are logged with full context before responding - -### Adding Errors to New Domains - -When creating a new domain, define domain-specific errors: - -```go -// internal/product/domain/product.go -var ( - ErrProductNotFound = errors.Wrap(errors.ErrNotFound, "product not found") - ErrInsufficientStock = errors.Wrap(errors.ErrConflict, "insufficient stock") - ErrInvalidPrice = errors.Wrap(errors.ErrInvalidInput, "invalid price") -) -``` - -Then use `httputil.HandleError()` in your HTTP handlers for automatic mapping. +Learn more in the [Error Handling Guide](docs/error-handling.md). -## Development - -### Build the application - -```bash -make build -``` - -### Testing - -**Start test databases:** -```bash -make test-db-up -``` - -**Run tests:** -```bash -make test -``` - -**Run tests with automatic database management:** -```bash -make test-with-db # Starts databases, runs tests, stops databases -``` - -**Run tests with coverage:** -```bash -make test-coverage -``` - -**Stop test databases:** -```bash -make test-db-down -``` - -See the [Testing](#testing) section for more details. - -### Run linter - -```bash -make lint -``` +### UUIDv7 Primary Keys -### Clean build artifacts +All entities use UUIDv7 for primary keys: +- ⏱️ Time-ordered for better database performance +- 🌍 Globally unique across distributed systems +- 📊 Better than UUIDv4 for database indexes -```bash -make clean -``` +### Transactional Outbox Pattern -## Docker +Ensures reliable event delivery: +1. Business operation and event stored in same transaction +2. Background worker processes pending events +3. Guarantees at-least-once delivery -### Build Docker image +## 🐳 Docker ```bash +# Build Docker image make docker-build -``` - -### Run server in Docker -```bash +# Run server in Docker make docker-run-server -``` - -### Run worker in Docker -```bash +# Run worker in Docker make docker-run-worker -``` - -### Run migrations in Docker -```bash +# Run migrations in Docker make docker-run-migrate ``` -## Architecture - -### Database Repository Pattern +## 🔧 Configuration -The project uses separate repository implementations for MySQL and PostgreSQL, leveraging Go's standard `database/sql` package. This approach provides: +All configuration is done via environment variables. Create a `.env` file in your project root: -- **Database-specific optimizations** - Each implementation is tailored to the specific database's features and syntax -- **Type safety** - Direct use of database/sql types without abstraction layers -- **Clarity** - Explicit SQL queries make it clear what operations are being performed -- **No external dependencies** - Uses only the standard library for database operations - -**Repository Interface** (defined in use case layer): - -```go -// internal/user/usecase/user_usecase.go -type UserRepository interface { - Create(ctx context.Context, user *domain.User) error - GetByID(ctx context.Context, id uuid.UUID) (*domain.User, error) - GetByEmail(ctx context.Context, email string) (*domain.User, error) -} -``` - -**MySQL Implementation** (`internal/user/repository/mysql_user_repository.go`): - -```go -func (r *MySQLUserRepository) Create(ctx context.Context, user *domain.User) error { - querier := database.GetTx(ctx, r.db) - - query := `INSERT INTO users (id, name, email, password, created_at, updated_at) - VALUES (?, ?, ?, ?, NOW(), NOW())` - - // Convert UUID to bytes for MySQL BINARY(16) - uuidBytes, err := user.ID.MarshalBinary() - if err != nil { - return apperrors.Wrap(err, "failed to marshal UUID") - } - - _, err = querier.ExecContext(ctx, query, uuidBytes, user.Name, user.Email, user.Password) - if err != nil { - if isMySQLUniqueViolation(err) { - return domain.ErrUserAlreadyExists - } - return apperrors.Wrap(err, "failed to create user") - } - return nil -} -``` - -**PostgreSQL Implementation** (`internal/user/repository/postgresql_user_repository.go`): - -```go -func (r *PostgreSQLUserRepository) Create(ctx context.Context, user *domain.User) error { - querier := database.GetTx(ctx, r.db) - - query := `INSERT INTO users (id, name, email, password, created_at, updated_at) - VALUES ($1, $2, $3, $4, NOW(), NOW())` - - _, err := querier.ExecContext(ctx, query, user.ID, user.Name, user.Email, user.Password) - if err != nil { - if isPostgreSQLUniqueViolation(err) { - return domain.ErrUserAlreadyExists - } - return apperrors.Wrap(err, "failed to create user") - } - return nil -} -``` - -**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" | - -**Transaction Support:** - -The `database.GetTx()` helper function retrieves the transaction from context if available, otherwise returns the DB connection: - -```go -// internal/database/txmanager.go -type Querier interface { - ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) - QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) - QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row -} - -func GetTx(ctx context.Context, db *sql.DB) Querier { - if tx, ok := ctx.Value(txKey{}).(*sql.Tx); ok { - return tx - } - return db -} -``` - -This pattern ensures repositories work seamlessly within transactions managed by the use case layer. - -### Testing Approach - -The project uses **integration testing with real databases** instead of mocks for repository layer tests. This approach provides: - -- **Accuracy** - Tests verify actual SQL queries and database behavior -- **Real Integration** - Catches database-specific issues (constraints, types, unique violations, etc.) -- **Production Parity** - Tests reflect real production scenarios -- **Less Maintenance** - No mock expectations to maintain or update -- **Confidence** - Full database integration coverage - -**Test Infrastructure:** - -Tests use Docker Compose to spin up isolated test databases (PostgreSQL on port 5433, MySQL on port 3307) with dedicated test credentials. The `testutil` package provides helper functions that: - -1. Connect to test databases -2. Run migrations automatically -3. Clean up data between tests -4. Provide isolated test environments - -**Example test structure:** - -```go -func TestPostgreSQLUserRepository_Create(t *testing.T) { - db := testutil.SetupPostgresDB(t) // Connect and run migrations - defer testutil.TeardownDB(t, db) // Clean up connection - defer testutil.CleanupPostgresDB(t, db) // Clean up test data - - repo := NewPostgreSQLUserRepository(db) - ctx := context.Background() - - user := &domain.User{ - ID: uuid.Must(uuid.NewV7()), - Name: "John Doe", - Email: "john@example.com", - Password: "hashed_password", - } - - err := repo.Create(ctx, user) - assert.NoError(t, err) - - // Verify by querying the real database - createdUser, err := repo.GetByID(ctx, user.ID) - assert.NoError(t, err) - assert.Equal(t, user.Name, createdUser.Name) -} -``` - -This testing strategy ensures repository implementations work correctly with actual databases while maintaining fast test execution (~15 seconds for the full test suite). - -### 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: - -**User Domain** (`internal/user/`) -- `domain/` - User entity and types -- `usecase/` - UseCase interface and user business logic implementation -- `repository/` - User data persistence -- `http/` - User HTTP endpoints and handlers - - `dto/` - Request/response DTOs and mappers - -**Outbox Domain** (`internal/outbox/`) -- `domain/` - OutboxEvent entity and status types -- `repository/` - Event persistence and retrieval - -**Shared Infrastructure** -- `app/` - Dependency injection container for component assembly -- `config/` - Application configuration -- `database/` - Database connection and transaction management -- `errors/` - Standardized domain errors (ErrNotFound, ErrConflict, etc.) -- `http/` - HTTP server, middleware, and shared utilities -- `httputil/` - Reusable HTTP utilities (JSON responses, error handling, status code mapping) -- `worker/` - Background event processing - -### Benefits of This Structure - -1. **Scalability** - Easy to add new domains without affecting existing code -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/ -│ └── product.go # Domain entity + domain errors -├── usecase/ -│ └── product_usecase.go # UseCase interface + business logic -├── repository/ -│ ├── mysql_product_repository.go # MySQL data access -│ └── postgresql_product_repository.go # PostgreSQL data access -└── http/ - ├── dto/ - │ ├── request.go # API request DTOs - │ ├── response.go # API response DTOs - │ └── mapper.go # DTO-to-domain mappers - └── product_handler.go # HTTP handlers (uses httputil.HandleError) -``` - -**Define domain errors in your entity file:** - -```go -// internal/product/domain/product.go -package domain - -import ( - "time" - apperrors "github.com/yourname/yourproject/internal/errors" - "github.com/google/uuid" -) - -type Product struct { - ID uuid.UUID - Name string - Price float64 - Stock int - CreatedAt time.Time - UpdatedAt time.Time -} - -// Domain-specific errors -var ( - ErrProductNotFound = apperrors.Wrap(apperrors.ErrNotFound, "product not found") - ErrInsufficientStock = apperrors.Wrap(apperrors.ErrConflict, "insufficient stock") - ErrInvalidPrice = apperrors.Wrap(apperrors.ErrInvalidInput, "invalid price") -) -``` - -#### 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.UseCase // Interface, not concrete type - 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 -} - -func (c *Container) ProductUseCase() (productUsecase.UseCase, error) { - var err error - c.productUseCaseInit.Do(func() { - c.productUseCase, err = c.initProductUseCase() - if err != nil { - c.initErrors["productUseCase"] = err - } - }) - // ... error handling - return c.productUseCase, nil -} - -// Add initialization methods -func (c *Container) initProductRepository() (productUsecase.ProductRepository, error) { - db, err := c.DB() - if err != nil { - return nil, fmt.Errorf("failed to get database: %w", err) - } - - // Select the appropriate repository based on the database driver - switch c.config.DBDriver { - case "mysql": - return productRepository.NewMySQLProductRepository(db), nil - case "postgres": - return productRepository.NewPostgreSQLProductRepository(db), nil - default: - return nil, fmt.Errorf("unsupported database driver: %s", c.config.DBDriver) - } -} - -func (c *Container) initProductUseCase() (productUsecase.UseCase, error) { - txManager, err := c.TxManager() - if err != nil { - return nil, fmt.Errorf("failed to get tx manager: %w", err) - } - - productRepo, err := c.ProductRepository() - if err != nil { - return nil, fmt.Errorf("failed to get product repository: %w", err) - } - - return productUsecase.NewProductUseCase(txManager, productRepo) -} -``` - -#### 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:** -- Define a UseCase interface in your usecase package to enable dependency inversion -- Define domain-specific errors in your domain package by wrapping standard errors -- Repository layer should transform infrastructure errors (like `sql.ErrNoRows`) to domain errors -- Use case layer should return domain errors directly without additional wrapping -- Use `httputil.HandleError()` in HTTP handlers for automatic error-to-HTTP status mapping -- Use the shared `httputil.MakeJSONResponse` function 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 -- HTTP handlers should depend on the UseCase interface, not concrete implementations - -### Clean Architecture Layers - -1. **Domain Layer** - Contains business entities, domain errors, and rules (e.g., `internal/user/domain`) -2. **Repository Layer** - Data access implementations using `database/sql`; transforms infrastructure errors to domain errors (e.g., `internal/user/repository`) -3. **Use Case Layer** - UseCase interfaces and application business logic; returns domain errors (e.g., `internal/user/usecase`) -4. **Presentation Layer** - HTTP handlers that map domain errors to HTTP responses (e.g., `internal/user/http`) -5. **Utility Layer** - Shared utilities including error handling and mapping (e.g., `internal/httputil`, `internal/errors`) - -**Dependency Inversion Principle:** The presentation layer (HTTP handlers) and infrastructure (DI container) depend on UseCase interfaces defined in the usecase layer, not on concrete implementations. This enables better testability and decoupling. - -**Error Flow:** Errors flow from repository → use case → handler, where they are transformed from infrastructure concerns to domain concepts, and finally to appropriate HTTP responses. - -### Data Transfer Objects (DTOs) - -The project enforces clear boundaries between internal domain models and external API contracts using DTOs: - -**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 validation -- `response.go` - API response structures -- `mapper.go` - Conversion functions between DTOs and domain models - -**Example:** - -```go -// Request DTO -type RegisterUserRequest struct { - Name string `json:"name"` - Email string `json:"email"` - Password string `json:"password"` -} - -func (r *RegisterUserRequest) Validate() error { - if r.Name == "" { - return errors.New("name is required") - } - // ... validation logic -} - -// Response DTO -type UserResponse struct { - ID uuid.UUID `json:"id"` - Name string `json:"name"` - Email string `json:"email"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -// Mapper functions -func ToRegisterUserInput(req RegisterUserRequest) usecase.RegisterUserInput { - return usecase.RegisterUserInput{ - Name: req.Name, - Email: req.Email, - Password: req.Password, - } -} - -func ToUserResponse(user *domain.User) UserResponse { - return UserResponse{ - ID: user.ID, - Name: user.Name, - Email: user.Email, - CreatedAt: user.CreatedAt, - UpdatedAt: user.UpdatedAt, - } -} -``` - -**Benefits:** -1. **Separation of Concerns** - Domain models evolve independently from API contracts -2. **Security** - Sensitive fields (like passwords) are never exposed in API responses -3. **Flexibility** - Different API views of the same domain model (e.g., summary vs detailed) -4. **Versioning** - Easy to maintain multiple API versions with different DTOs -5. **Validation** - Request validation happens at the DTO level before reaching domain logic - -### Input Validation - -The project uses the [jellydator/validation](https://github.com/jellydator/validation) library for comprehensive input validation at both the DTO and use case layers. - -**Custom Validation Rules** (`internal/validation/rules.go`) - -The project provides reusable validation rules: - -```go -// Password strength validation -PasswordStrength{ - MinLength: 8, - RequireUpper: true, - RequireLower: true, - RequireNumber: true, - RequireSpecial: true, -} - -// Email format validation -Email - -// No leading/trailing whitespace -NoWhitespace - -// Not blank after trimming -NotBlank -``` - -**DTO Validation Example:** - -```go -func (r *RegisterUserRequest) Validate() error { - err := validation.ValidateStruct(r, - validation.Field(&r.Name, - validation.Required.Error("name is required"), - appValidation.NotBlank, - validation.Length(1, 255).Error("name must be between 1 and 255 characters"), - ), - validation.Field(&r.Email, - validation.Required.Error("email is required"), - appValidation.NotBlank, - appValidation.Email, - validation.Length(5, 255).Error("email must be between 5 and 255 characters"), - ), - validation.Field(&r.Password, - validation.Required.Error("password is required"), - validation.Length(8, 128).Error("password must be between 8 and 128 characters"), - appValidation.PasswordStrength{ - MinLength: 8, - RequireUpper: true, - RequireLower: true, - RequireNumber: true, - RequireSpecial: true, - }, - ), - ) - return appValidation.WrapValidationError(err) -} -``` - -**Validation Layers:** - -1. **DTO Layer** - Validates API request structure and basic constraints -2. **Use Case Layer** - Validates business logic rules and constraints -3. **Domain Layer** - Defines domain-specific error types - -**Error Responses:** - -Validation errors are automatically wrapped as `ErrInvalidInput` and return 422 Unprocessable Entity: - -```json -{ - "error": "invalid_input", - "message": "password: password must contain at least one uppercase letter." -} -``` - -**Benefits:** -- **Declarative** - Validation rules are clear and concise -- **Reusable** - Custom rules can be shared across the application -- **Type-Safe** - Compile-time validation of struct fields -- **Extensible** - Easy to add custom validation rules -- **Consistent** - Same validation logic at DTO and use case layers -- **User-Friendly** - Detailed error messages help API clients fix issues - -### Transaction Management - -The template implements a TxManager interface for handling database transactions: - -```go -type TxManager interface { - WithTx(ctx context.Context, fn func(ctx context.Context) error) error -} -``` - -Transactions are automatically injected into the context and used by repositories. - -### HTTP Utilities - -The `httputil` package provides shared HTTP utilities used across all domain modules: - -**MakeJSONResponse** - Standardized JSON response formatting: - -```go -import "github.com/allisson/go-project-template/internal/httputil" - -func (h *ProductHandler) GetProduct(w http.ResponseWriter, r *http.Request) { - product, err := h.productUseCase.GetProduct(r.Context(), productID) - if err != nil { - httputil.HandleError(w, err, h.logger) - return - } - - httputil.MakeJSONResponse(w, http.StatusOK, product) -} -``` - -**HandleError** - Automatic domain error to HTTP status code mapping: - -```go -func (h *UserHandler) RegisterUser(w http.ResponseWriter, r *http.Request) { - user, err := h.userUseCase.RegisterUser(r.Context(), input) - if err != nil { - // Automatically maps domain errors to appropriate HTTP status codes - // ErrNotFound → 404, ErrConflict → 409, ErrInvalidInput → 422, etc. - httputil.HandleError(w, err, h.logger) - return - } - httputil.MakeJSONResponse(w, http.StatusCreated, response) -} -``` - -**HandleValidationError** - For JSON decode and validation errors: - -```go -if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - httputil.HandleValidationError(w, err, h.logger) // Returns 400 Bad Request - return -} -``` - -These utilities ensure consistent response formatting and error handling across all HTTP endpoints. - -### Transactional Outbox Pattern - -User registration demonstrates the transactional outbox pattern: - -1. User is created in the database -2. `user.created` event is stored in the outbox table (same transaction) -3. Worker picks up pending events and processes them -4. Events are marked as processed or failed - -This guarantees that events are never lost and provides at-least-once delivery. - -## Configuration - -All configuration is done via environment variables. The application automatically loads a `.env` file if present (searching recursively from the current directory up to the root). - -### Environment Variables - -| Variable | Description | Default | -|----------|-------------|---------| -| `SERVER_HOST` | HTTP server host | `0.0.0.0` | -| `SERVER_PORT` | HTTP server port | `8080` | -| `DB_DRIVER` | Database driver (postgres/mysql) | `postgres` | -| `DB_CONNECTION_STRING` | Database connection string | `postgres://user:password@localhost:5432/mydb?sslmode=disable` | -| `DB_MAX_OPEN_CONNECTIONS` | Max open connections | `25` | -| `DB_MAX_IDLE_CONNECTIONS` | Max idle connections | `5` | -| `DB_CONN_MAX_LIFETIME` | Connection max lifetime | `5` | -| `LOG_LEVEL` | Log level (debug/info/warn/error) | `info` | -| `WORKER_INTERVAL` | Worker poll interval | `5` | -| `WORKER_BATCH_SIZE` | Events to process per batch | `10` | -| `WORKER_MAX_RETRIES` | Max retry attempts | `3` | -| `WORKER_RETRY_INTERVAL` | Retry interval | `1` | - -## Database Migrations - -Migrations are located in `migrations/postgresql` and `migrations/mysql` directories. - -### Creating new migrations - -1. Create new `.up.sql` and `.down.sql` files with sequential numbering -2. Follow the naming convention: `000003_description.up.sql` - -### Running migrations manually - -Use the golang-migrate CLI: - -```bash -migrate -path migrations/postgresql -database "postgres://user:password@localhost:5432/mydb?sslmode=disable" up -``` - -## Testing - -The project uses real databases (PostgreSQL and MySQL) for testing instead of mocks, ensuring tests accurately reflect production behavior. - -### Test Infrastructure - -Tests use Docker Compose to run test databases with dedicated test credentials and ports: - -- **PostgreSQL**: `localhost:5433` (testuser/testpassword/testdb) -- **MySQL**: `localhost:3307` (testuser/testpassword/testdb) - -The test helper utilities (`internal/testutil/database.go`) automatically: -1. Connect to test databases -2. Run migrations automatically before tests -3. Clean up data between tests to prevent pollution -4. Provide isolated test environments - -**Important:** Both local development (via Docker Compose) and CI (via GitHub Actions) use identical database configurations, ensuring tests behave the same in all environments. - -### Running Tests - -**Start test databases:** -```bash -make test-db-up -``` - -**Run all tests:** ```bash -make test -``` - -**Run tests with coverage:** -```bash -make test-coverage -``` - -**Run tests and manage databases automatically:** -```bash -make test-with-db # Starts databases, runs tests, stops databases -``` - -**Stop test databases:** -```bash -make test-db-down -``` - -### Running Tests Locally - -```bash -# With test databases already running -go test -v -race ./... -``` - -### Test Structure - -Tests use real database connections instead of mocks: - -```go -func TestPostgreSQLUserRepository_Create(t *testing.T) { - db := testutil.SetupPostgresDB(t) // Connect and run migrations - defer testutil.TeardownDB(t, db) // Clean up connection - defer testutil.CleanupPostgresDB(t, db) // Clean up test data - - repo := NewPostgreSQLUserRepository(db) - // ... test implementation -} +DB_DRIVER=postgres +DB_CONNECTION_STRING=postgres://user:password@localhost:5432/mydb?sslmode=disable +SERVER_HOST=0.0.0.0 +SERVER_PORT=8080 +LOG_LEVEL=info ``` -**Benefits of using real databases:** -- Tests verify actual SQL queries and database interactions -- Catches database-specific issues (constraints, types, etc.) -- Tests reflect production behavior more accurately -- No need to maintain mock expectations +See the [Getting Started Guide](docs/getting-started.md) for all available configuration options. -### CI/CD Testing +## ➕ Adding New Domains -The GitHub Actions workflow automatically: -1. Starts PostgreSQL (port 5433) and MySQL (port 3307) containers -2. Waits for both databases to be healthy -3. Runs all tests with race detection against both databases -4. Generates and uploads coverage reports to Codecov +Adding a new domain is straightforward: -**CI Configuration:** -- Uses the same database credentials as local tests (testuser/testpassword/testdb) -- Same port mappings as Docker Compose (5433 for Postgres, 3307 for MySQL) -- Runs on every push to `main` and all pull requests -- All tests must pass before merging +1. Create domain structure (`domain/`, `usecase/`, `repository/`, `http/`) +2. Define domain entity and errors +3. Create database migrations +4. Implement repositories (PostgreSQL and MySQL) +5. Implement use case with business logic +6. Create DTOs and HTTP handlers +7. Register in DI container +8. Wire HTTP routes -This ensures complete consistency between local development and CI environments. +See the [Adding Domains Guide](docs/adding-domains.md) for a complete step-by-step tutorial. -## Dependencies +## 📦 Dependencies ### Core Libraries -- [go-env](https://github.com/allisson/go-env) - Environment variable configuration -- [godotenv](https://github.com/joho/godotenv) - Loads environment variables from .env files -- [go-pwdhash](https://github.com/allisson/go-pwdhash) - Password hashing with Argon2id -- [validation](https://github.com/jellydator/validation) - Advanced input validation library -- [uuid](https://github.com/google/uuid) - UUID generation including UUIDv7 support +- [google/uuid](https://github.com/google/uuid) - UUID generation (UUIDv7 support) +- [jellydator/validation](https://github.com/jellydator/validation) - Advanced input validation - [urfave/cli](https://github.com/urfave/cli) - CLI framework -- [golang-migrate](https://github.com/golang-migrate/migrate) - Database migrations +- [allisson/go-env](https://github.com/allisson/go-env) - Environment configuration +- [allisson/go-pwdhash](https://github.com/allisson/go-pwdhash) - Argon2id password hashing +- [golang-migrate/migrate](https://github.com/golang-migrate/migrate) - Database migrations ### Database Drivers - [lib/pq](https://github.com/lib/pq) - PostgreSQL driver - [go-sql-driver/mysql](https://github.com/go-sql-driver/mysql) - MySQL driver -## License +## 🤝 Contributing -MIT License - see LICENSE file for details +Contributions are welcome! Please feel free to submit a Pull Request. -## Contributing +1. Fork the repository +2. Create your feature branch (`git checkout -b feature/amazing-feature`) +3. Commit your changes (`git commit -m 'feat: add amazing feature'`) +4. Push to the branch (`git push origin feature/amazing-feature`) +5. Open a Pull Request -Contributions are welcome! Please feel free to submit a Pull Request. +## 📄 License -## Acknowledgments +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. -This template uses the following excellent Go libraries: +## 🙏 Acknowledgments + +This template leverages these excellent Go libraries: - github.com/allisson/go-env - github.com/allisson/go-pwdhash - github.com/jellydator/validation +- github.com/google/uuid - github.com/urfave/cli -- github.com/golang-migrate/migrate \ No newline at end of file +- github.com/golang-migrate/migrate + +--- + +
+ Built with ❤️ by Allisson +
diff --git a/docs/adding-domains.md b/docs/adding-domains.md new file mode 100644 index 0000000..8d21003 --- /dev/null +++ b/docs/adding-domains.md @@ -0,0 +1,724 @@ +# ➕ Adding New Domains + +This guide walks you through adding a new domain to the project. We'll use a `product` domain as an example. + +## 🏗️ Domain Structure + +Each domain follows this structure: + +``` +internal/product/ +├── domain/ +│ └── product.go # Entity + domain errors +├── usecase/ +│ └── product_usecase.go # UseCase interface + implementation +├── repository/ +│ ├── mysql_product_repository.go +│ └── postgresql_product_repository.go +└── http/ + ├── dto/ + │ ├── request.go # Request DTOs with validation + │ ├── response.go # Response DTOs with JSON tags + │ └── mapper.go # DTO ↔ domain conversions + └── product_handler.go # HTTP handlers +``` + +## 📝 Step-by-Step Guide + +### Step 1: Create Domain Entity + +Create `internal/product/domain/product.go`: + +```go +package domain + +import ( + "time" + + "github.com/google/uuid" + + apperrors "github.com/yourproject/internal/errors" +) + +// Product represents a product in the system +type Product struct { + ID uuid.UUID + Name string + Description string + Price float64 + Stock int + CreatedAt time.Time + UpdatedAt time.Time +} + +// Domain-specific errors +var ( + ErrProductNotFound = apperrors.Wrap(apperrors.ErrNotFound, "product not found") + ErrInsufficientStock = apperrors.Wrap(apperrors.ErrConflict, "insufficient stock") + ErrInvalidPrice = apperrors.Wrap(apperrors.ErrInvalidInput, "invalid price") + ErrInvalidQuantity = apperrors.Wrap(apperrors.ErrInvalidInput, "quantity must be positive") +) +``` + +**Key Points**: +- ✅ No JSON tags (domain models are pure) +- ✅ Use `uuid.UUID` for ID +- ✅ Wrap standard errors with domain-specific messages + +### Step 2: Create Database Migrations + +Create migration files for both databases: + +**PostgreSQL** (`migrations/postgresql/000003_create_products_table.up.sql`): + +```sql +CREATE TABLE products ( + id UUID PRIMARY KEY, + name VARCHAR(255) NOT NULL, + description TEXT, + price DECIMAL(10, 2) NOT NULL, + stock INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_products_name ON products(name); +``` + +**PostgreSQL Down** (`migrations/postgresql/000003_create_products_table.down.sql`): + +```sql +DROP INDEX IF EXISTS idx_products_name; +DROP TABLE IF EXISTS products; +``` + +**MySQL** (`migrations/mysql/000003_create_products_table.up.sql`): + +```sql +CREATE TABLE products ( + id BINARY(16) PRIMARY KEY, + name VARCHAR(255) NOT NULL, + description TEXT, + price DECIMAL(10, 2) NOT NULL, + stock INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_products_name (name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +``` + +**MySQL Down** (`migrations/mysql/000003_create_products_table.down.sql`): + +```sql +DROP TABLE IF EXISTS products; +``` + +Run migrations: +```bash +make run-migrate +``` + +### Step 3: Create Repository Interface & Implementations + +**Define Repository Interface** in `internal/product/usecase/product_usecase.go`: + +```go +package usecase + +import ( + "context" + + "github.com/google/uuid" + + "github.com/yourproject/internal/product/domain" +) + +// ProductRepository defines the interface for product data access +type ProductRepository interface { + Create(ctx context.Context, product *domain.Product) error + GetByID(ctx context.Context, id uuid.UUID) (*domain.Product, error) + Update(ctx context.Context, product *domain.Product) error + Delete(ctx context.Context, id uuid.UUID) error + List(ctx context.Context, limit, offset int) ([]*domain.Product, error) +} +``` + +**PostgreSQL Implementation** (`internal/product/repository/postgresql_product_repository.go`): + +```go +package repository + +import ( + "context" + "database/sql" + "errors" + "strings" + + "github.com/google/uuid" + + apperrors "github.com/yourproject/internal/errors" + "github.com/yourproject/internal/database" + "github.com/yourproject/internal/product/domain" +) + +type PostgreSQLProductRepository struct { + db *sql.DB +} + +func NewPostgreSQLProductRepository(db *sql.DB) *PostgreSQLProductRepository { + return &PostgreSQLProductRepository{db: db} +} + +func (r *PostgreSQLProductRepository) Create(ctx context.Context, product *domain.Product) error { + querier := database.GetTx(ctx, r.db) + + query := `INSERT INTO products (id, name, description, price, stock, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, NOW(), NOW())` + + _, err := querier.ExecContext(ctx, query, + product.ID, + product.Name, + product.Description, + product.Price, + product.Stock, + ) + if err != nil { + if isPostgreSQLUniqueViolation(err) { + return apperrors.Wrap(apperrors.ErrConflict, "product with this name already exists") + } + return apperrors.Wrap(err, "failed to create product") + } + return nil +} + +func (r *PostgreSQLProductRepository) GetByID(ctx context.Context, id uuid.UUID) (*domain.Product, error) { + querier := database.GetTx(ctx, r.db) + + query := `SELECT id, name, description, price, stock, created_at, updated_at + FROM products WHERE id = $1` + + var product domain.Product + err := querier.QueryRowContext(ctx, query, id).Scan( + &product.ID, + &product.Name, + &product.Description, + &product.Price, + &product.Stock, + &product.CreatedAt, + &product.UpdatedAt, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, domain.ErrProductNotFound + } + return nil, apperrors.Wrap(err, "failed to get product by id") + } + return &product, nil +} + +func isPostgreSQLUniqueViolation(err error) bool { + return strings.Contains(err.Error(), "duplicate key") || + strings.Contains(err.Error(), "unique constraint") +} +``` + +**MySQL Implementation** (`internal/product/repository/mysql_product_repository.go`): + +```go +package repository + +import ( + "context" + "database/sql" + "errors" + "strings" + + "github.com/google/uuid" + + apperrors "github.com/yourproject/internal/errors" + "github.com/yourproject/internal/database" + "github.com/yourproject/internal/product/domain" +) + +type MySQLProductRepository struct { + db *sql.DB +} + +func NewMySQLProductRepository(db *sql.DB) *MySQLProductRepository { + return &MySQLProductRepository{db: db} +} + +func (r *MySQLProductRepository) Create(ctx context.Context, product *domain.Product) error { + querier := database.GetTx(ctx, r.db) + + query := `INSERT INTO products (id, name, description, price, stock, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, NOW(), NOW())` + + // Convert UUID to bytes for MySQL BINARY(16) + uuidBytes, err := product.ID.MarshalBinary() + if err != nil { + return apperrors.Wrap(err, "failed to marshal UUID") + } + + _, err = querier.ExecContext(ctx, query, + uuidBytes, + product.Name, + product.Description, + product.Price, + product.Stock, + ) + if err != nil { + if isMySQLUniqueViolation(err) { + return apperrors.Wrap(apperrors.ErrConflict, "product with this name already exists") + } + return apperrors.Wrap(err, "failed to create product") + } + return nil +} + +func isMySQLUniqueViolation(err error) bool { + return strings.Contains(err.Error(), "Error 1062") || + strings.Contains(err.Error(), "Duplicate entry") +} +``` + +### Step 4: Create Use Case + +Create `internal/product/usecase/product_usecase.go`: + +```go +package usecase + +import ( + "context" + + "github.com/google/uuid" + validation "github.com/jellydator/validation" + + apperrors "github.com/yourproject/internal/errors" + "github.com/yourproject/internal/database" + "github.com/yourproject/internal/product/domain" +) + +// UseCase defines the interface for product business logic +type UseCase interface { + CreateProduct(ctx context.Context, input CreateProductInput) (*domain.Product, error) + GetProduct(ctx context.Context, id uuid.UUID) (*domain.Product, error) + UpdateProduct(ctx context.Context, id uuid.UUID, input UpdateProductInput) (*domain.Product, error) + DeleteProduct(ctx context.Context, id uuid.UUID) error + ListProducts(ctx context.Context, limit, offset int) ([]*domain.Product, error) +} + +type ProductUseCase struct { + txManager database.TxManager + productRepo ProductRepository +} + +func NewProductUseCase(txManager database.TxManager, productRepo ProductRepository) *ProductUseCase { + return &ProductUseCase{ + txManager: txManager, + productRepo: productRepo, + } +} + +// CreateProductInput represents the input for creating a product +type CreateProductInput struct { + Name string + Description string + Price float64 + Stock int +} + +func (i CreateProductInput) Validate() error { + return validation.ValidateStruct(&i, + validation.Field(&i.Name, validation.Required, validation.Length(1, 255)), + validation.Field(&i.Price, validation.Required, validation.Min(0.0)), + validation.Field(&i.Stock, validation.Required, validation.Min(0)), + ) +} + +func (uc *ProductUseCase) CreateProduct(ctx context.Context, input CreateProductInput) (*domain.Product, error) { + if err := input.Validate(); err != nil { + return nil, apperrors.Wrap(apperrors.ErrInvalidInput, err.Error()) + } + + if input.Price <= 0 { + return nil, domain.ErrInvalidPrice + } + + if input.Stock < 0 { + return nil, domain.ErrInvalidQuantity + } + + product := &domain.Product{ + ID: uuid.Must(uuid.NewV7()), + Name: input.Name, + Description: input.Description, + Price: input.Price, + Stock: input.Stock, + } + + if err := uc.productRepo.Create(ctx, product); err != nil { + return nil, err + } + + return product, nil +} + +func (uc *ProductUseCase) GetProduct(ctx context.Context, id uuid.UUID) (*domain.Product, error) { + return uc.productRepo.GetByID(ctx, id) +} +``` + +### Step 5: Create DTOs + +**Request DTO** (`internal/product/http/dto/request.go`): + +```go +package dto + +import ( + validation "github.com/jellydator/validation" + + appValidation "github.com/yourproject/internal/validation" +) + +type CreateProductRequest struct { + Name string `json:"name"` + Description string `json:"description"` + Price float64 `json:"price"` + Stock int `json:"stock"` +} + +func (r *CreateProductRequest) Validate() error { + err := validation.ValidateStruct(r, + validation.Field(&r.Name, + validation.Required.Error("name is required"), + appValidation.NotBlank, + validation.Length(1, 255), + ), + validation.Field(&r.Price, + validation.Required.Error("price is required"), + validation.Min(0.01).Error("price must be greater than 0"), + ), + validation.Field(&r.Stock, + validation.Required.Error("stock is required"), + validation.Min(0).Error("stock cannot be negative"), + ), + ) + return appValidation.WrapValidationError(err) +} +``` + +**Response DTO** (`internal/product/http/dto/response.go`): + +```go +package dto + +import ( + "time" + + "github.com/google/uuid" +) + +type ProductResponse struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Price float64 `json:"price"` + Stock int `json:"stock"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} +``` + +**Mapper** (`internal/product/http/dto/mapper.go`): + +```go +package dto + +import ( + "github.com/yourproject/internal/product/domain" + "github.com/yourproject/internal/product/usecase" +) + +func ToCreateProductInput(req CreateProductRequest) usecase.CreateProductInput { + return usecase.CreateProductInput{ + Name: req.Name, + Description: req.Description, + Price: req.Price, + Stock: req.Stock, + } +} + +func ToProductResponse(product *domain.Product) ProductResponse { + return ProductResponse{ + ID: product.ID, + Name: product.Name, + Description: product.Description, + Price: product.Price, + Stock: product.Stock, + CreatedAt: product.CreatedAt, + UpdatedAt: product.UpdatedAt, + } +} + +func ToProductListResponse(products []*domain.Product) []ProductResponse { + responses := make([]ProductResponse, len(products)) + for i, product := range products { + responses[i] = ToProductResponse(product) + } + return responses +} +``` + +### Step 6: Create HTTP Handler + +Create `internal/product/http/product_handler.go`: + +```go +package http + +import ( + "encoding/json" + "log/slog" + "net/http" + + "github.com/google/uuid" + + "github.com/yourproject/internal/httputil" + "github.com/yourproject/internal/product/http/dto" + "github.com/yourproject/internal/product/usecase" +) + +type ProductHandler struct { + productUseCase usecase.UseCase + logger *slog.Logger +} + +func NewProductHandler(productUseCase usecase.UseCase, logger *slog.Logger) *ProductHandler { + return &ProductHandler{ + productUseCase: productUseCase, + logger: logger, + } +} + +func (h *ProductHandler) CreateProduct(w http.ResponseWriter, r *http.Request) { + var req dto.CreateProductRequest + + 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.ToCreateProductInput(req) + product, err := h.productUseCase.CreateProduct(r.Context(), input) + if err != nil { + httputil.HandleError(w, err, h.logger) + return + } + + response := dto.ToProductResponse(product) + httputil.MakeJSONResponse(w, http.StatusCreated, response) +} + +func (h *ProductHandler) GetProduct(w http.ResponseWriter, r *http.Request) { + // Parse product ID from URL + idStr := r.PathValue("id") + id, err := uuid.Parse(idStr) + if err != nil { + httputil.HandleValidationError(w, err, h.logger) + return + } + + product, err := h.productUseCase.GetProduct(r.Context(), id) + if err != nil { + httputil.HandleError(w, err, h.logger) + return + } + + response := dto.ToProductResponse(product) + httputil.MakeJSONResponse(w, http.StatusOK, response) +} +``` + +### Step 7: Register in DI Container + +Update `internal/app/di.go`: + +```go +type Container struct { + // ... existing fields + productRepo productUsecase.ProductRepository + productUseCase productUsecase.UseCase // Interface, not concrete type! + productRepoInit sync.Once + productUseCaseInit sync.Once +} + +func (c *Container) ProductRepository() (productUsecase.ProductRepository, error) { + var err error + c.productRepoInit.Do(func() { + c.productRepo, err = c.initProductRepository() + if err != nil { + c.initErrors["productRepo"] = err + } + }) + if err != nil { + return nil, err + } + if existingErr, ok := c.initErrors["productRepo"]; ok { + return nil, existingErr + } + return c.productRepo, nil +} + +func (c *Container) ProductUseCase() (productUsecase.UseCase, error) { + var err error + c.productUseCaseInit.Do(func() { + c.productUseCase, err = c.initProductUseCase() + if err != nil { + c.initErrors["productUseCase"] = err + } + }) + if err != nil { + return nil, err + } + if existingErr, ok := c.initErrors["productUseCase"]; ok { + return nil, existingErr + } + return c.productUseCase, nil +} + +func (c *Container) initProductRepository() (productUsecase.ProductRepository, error) { + db, err := c.DB() + if err != nil { + return nil, fmt.Errorf("failed to get database: %w", err) + } + + switch c.config.DBDriver { + case "mysql": + return productRepository.NewMySQLProductRepository(db), nil + case "postgres": + return productRepository.NewPostgreSQLProductRepository(db), nil + default: + return nil, fmt.Errorf("unsupported database driver: %s", c.config.DBDriver) + } +} + +func (c *Container) initProductUseCase() (productUsecase.UseCase, error) { + txManager, err := c.TxManager() + if err != nil { + return nil, fmt.Errorf("failed to get tx manager: %w", err) + } + + productRepo, err := c.ProductRepository() + if err != nil { + return nil, fmt.Errorf("failed to get product repository: %w", err) + } + + return productUsecase.NewProductUseCase(txManager, productRepo), nil +} +``` + +### Step 8: Wire HTTP Routes + +Update `internal/http/server.go`: + +```go +func (s *Server) setupRoutes() { + mux := http.NewServeMux() + + // Health checks + mux.HandleFunc("/health", s.handleHealth) + mux.HandleFunc("/ready", s.handleReady) + + // User routes + userHandler := userHttp.NewUserHandler(s.container.UserUseCase(), s.logger) + mux.HandleFunc("POST /api/users", userHandler.RegisterUser) + + // Product routes + productHandler := productHttp.NewProductHandler(s.container.ProductUseCase(), s.logger) + mux.HandleFunc("POST /api/products", productHandler.CreateProduct) + mux.HandleFunc("GET /api/products/{id}", productHandler.GetProduct) + + s.handler = s.middleware(mux) +} +``` + +### Step 9: Write Tests + +Create tests for your new domain following the [Testing Guide](testing.md). + +### Step 10: Test Your New Domain + +```bash +# Run migrations +make run-migrate + +# Start server +make run-server + +# Create a product +curl -X POST http://localhost:8080/api/products \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Laptop", + "description": "High-performance laptop", + "price": 999.99, + "stock": 10 + }' + +# Get a product +curl http://localhost:8080/api/products/{id} +``` + +## ✅ Checklist + +Use this checklist when adding a new domain: + +- [ ] Create domain entity with business logic +- [ ] Define domain-specific errors +- [ ] Create database migrations (PostgreSQL and MySQL) +- [ ] Run migrations +- [ ] Define repository interface in use case package +- [ ] Implement PostgreSQL repository +- [ ] Implement MySQL repository +- [ ] Create use case interface +- [ ] Implement use case with business logic +- [ ] Create request DTOs with validation +- [ ] Create response DTOs with JSON tags +- [ ] Create mapper functions +- [ ] Create HTTP handlers +- [ ] Register repositories in DI container +- [ ] Register use cases in DI container +- [ ] Wire HTTP routes in server +- [ ] Write repository tests (PostgreSQL and MySQL) +- [ ] Write use case tests +- [ ] Write HTTP handler tests +- [ ] Run linter and fix issues +- [ ] Test endpoints manually +- [ ] Update documentation if needed + +## 🎯 Best Practices + +- ✅ **Follow existing patterns** - Look at the `user` domain for reference +- ✅ **Keep domain models pure** - No JSON tags, no framework dependencies +- ✅ **Define clear interfaces** - Use case and repository interfaces +- ✅ **Wrap standard errors** - Create domain-specific errors +- ✅ **Validate input** - At both DTO and use case levels +- ✅ **Use transactions** - When multiple operations must be atomic +- ✅ **Write tests** - Integration tests with real databases +- ✅ **Document code** - Add comments for complex logic +- ✅ **Run linter** - Before committing changes + +## 📚 Related Documentation + +- [Architecture](architecture.md) - Understand the architectural patterns +- [Error Handling](error-handling.md) - Learn the error handling system +- [Development](development.md) - Development workflow and guidelines +- [Testing](testing.md) - Writing effective tests diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..499c623 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,406 @@ +# 🏗️ Architecture + +This document describes the architectural patterns and design principles used in this Go project template. + +## 📐 Clean Architecture Layers + +The project follows Clean Architecture principles with clear separation of concerns across five layers: + +### 1. Domain Layer 🎯 +**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**: +```go +// 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") +) +``` + +### 2. Repository Layer 💾 +**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**: +```go +// 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 +} +``` + +### 3. Use Case Layer 💼 +**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**: +```go +// 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 +} +``` + +### 4. Presentation Layer 🌐 +**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 validation +- `dto/response.go` - Response DTOs with JSON tags +- `dto/mapper.go` - Conversion functions between DTOs and domain models + +**Example**: +```go +// 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) +} +``` + +### 5. Utility Layer 🛠️ +**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.) + +## 🔄 Dependency Inversion Principle + +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 │ +└─────────────────────────────────────────┘ +``` + +## 📦 Modular Domain Architecture + +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/ +│ └── repository/ +└── {new-domain}/ # Easy to add new domains +``` + +**Benefits**: +1. 🎯 **Scalability** - Easy to add new domains without affecting existing code +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 + +## 🔌 Dependency Injection Container + +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) +└── Presentation (HTTP Server, Worker) +``` + +**Example**: +```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) +``` + +For more details, see [`internal/app/README.md`](../internal/app/README.md). + +## 🆔 UUIDv7 Primary Keys + +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**: +```go +import "github.com/google/uuid" + +user := &domain.User{ + ID: uuid.Must(uuid.NewV7()), + Name: input.Name, + Email: input.Email, + Password: hashedPassword, +} +``` + +**Database Storage**: +- **PostgreSQL**: `UUID` type (native support) +- **MySQL**: `BINARY(16)` type (16-byte storage) + +## 📋 Data Transfer Objects (DTOs) + +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 validation +- `response.go` - API response structures with JSON tags +- `mapper.go` - Conversion functions between DTOs and domain models + +**Benefits**: +1. 🔒 **Separation of Concerns** - Domain models evolve independently from API contracts +2. 🛡️ **Security** - Sensitive fields (like passwords) never exposed in API responses +3. 🔄 **Flexibility** - Different API views of same domain model +4. 📚 **Versioning** - Easy to maintain multiple API versions +5. ✅ **Validation** - Request validation happens at DTO level before reaching domain logic + +## 🔄 Transaction Management + +The template implements a TxManager interface for handling database transactions: + +```go +type TxManager interface { + WithTx(ctx context.Context, fn func(ctx context.Context) error) error +} +``` + +**Usage**: +```go +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()`. + +## 📤 Transactional Outbox Pattern + +The project demonstrates the transactional outbox pattern for reliable event delivery: + +1. 📝 Business operation (e.g., user creation) is executed +2. 📬 Event is stored in outbox table in **same transaction** +3. 🚀 Background worker picks up pending events +4. ✅ Events are marked as processed or failed + +**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 + +**Example (User Registration)**: +```go +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) +}) +``` + +The worker (`internal/worker/event_worker.go`) processes these events asynchronously. diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..f05082a --- /dev/null +++ b/docs/development.md @@ -0,0 +1,538 @@ +# 🛠️ Development Guide + +This guide covers the development workflow, coding standards, and best practices for this Go project. + +## 🔨 Build Commands + +### Building the Application + +```bash +make build # Build the application binary +``` + +The binary will be created at `./bin/app`. + +### Running Commands + +```bash +make run-server # Build and run HTTP server +make run-worker # Build and run background worker +make run-migrate # Build and run database migrations +``` + +### Other Commands + +```bash +make clean # Remove build artifacts and coverage files +make deps # Download and tidy dependencies +make help # Show all available make targets +``` + +## 🎨 Code Style Guidelines + +### Import Organization + +Follow this import grouping order (enforced by `goimports`): + +1. **Standard library packages** +2. **External packages** (third-party) +3. **Local project packages** (prefixed with your module path) + +**Example**: +```go +import ( + "context" + "database/sql" + "strings" + + "github.com/google/uuid" + validation "github.com/jellydator/validation" + + "github.com/allisson/go-project-template/internal/database" + "github.com/allisson/go-project-template/internal/errors" + "github.com/allisson/go-project-template/internal/user/domain" +) +``` + +**Import Aliases**: +When renaming imports, use descriptive aliases: +- `apperrors` for `internal/errors` +- `appValidation` for `internal/validation` +- `outboxDomain` for `internal/outbox/domain` + +### Formatting + +- **Line length**: Max 110 characters (enforced by `golines`) +- **Indentation**: Use tabs (tab-len: 4) +- **Comments**: Not shortened by golines +- Run `make lint` before committing to auto-fix formatting issues + +### Naming Conventions + +#### Packages +- Use lowercase, single-word names when possible +- Avoid underscores or mixed caps +- ✅ Good: `domain`, `usecase`, `repository`, `http` +- ❌ Bad: `user_domain`, `userDomain`, `UserDomain` + +#### Types +- Use PascalCase for exported types +- Use camelCase for unexported types +- Suffix interfaces with meaningful names +- ✅ Good: `UserRepository`, `TxManager`, `UseCase` +- ❌ Bad: `UserRepositoryInterface`, `IUserRepository` + +#### Functions/Methods +- Use PascalCase for exported functions +- Use camelCase for unexported functions +- Prefix boolean functions with `is`, `has`, `can`, or `should` +- ✅ Good: `isPostgreSQLUniqueViolation`, `hasUpperCase` + +#### Variables +- Use camelCase for short-lived variables +- Use descriptive names for longer-lived variables +- Avoid single-letter names except for: + - `i`, `j`, `k` (loops) + - `r` (receiver) + - `w` (http.ResponseWriter) + - `ctx` (context) + +#### Constants +- Use PascalCase for exported constants +- Use camelCase for unexported constants + +## 🔍 Linting + +### Running the Linter + +```bash +make lint # Run golangci-lint with auto-fix +``` + +Or directly: +```bash +golangci-lint run -v --fix # Direct linter command +``` + +The linter will: +- ✅ Check code formatting (goimports, golines) +- ✅ Detect potential bugs +- ✅ Enforce code style guidelines +- ✅ Check for common mistakes +- ✅ Automatically fix issues when possible + +### Linter Configuration + +The project uses `.golangci.yml` for linter configuration. Key settings: + +```yaml +linters: + enable: + - goimports # Auto-organize imports + - golines # Enforce line length + - errcheck # Check error handling + - gosimple # Simplify code + - govet # Report suspicious constructs + - staticcheck # Static analysis +``` + +## 📝 Comments and Documentation + +### Package Comments + +Every package should have a doc comment: + +```go +// Package usecase implements the user business logic and orchestrates user domain operations. +package usecase +``` + +### Exported Types + +Document all exported types, functions, and constants: + +```go +// User represents a user in the system +type User struct { ... } + +// RegisterUser registers a new user and creates a user.created event +func (uc *UserUseCase) RegisterUser(ctx context.Context, input RegisterUserInput) (*domain.User, error) { + // Implementation +} +``` + +### Implementation Comments + +Add comments for complex logic: + +```go +// Hash the password using Argon2id +hashedPassword, err := uc.passwordHasher.Hash([]byte(input.Password)) +``` + +## 🗄️ Database Patterns + +### Repository Pattern + +**Key Principles**: +- ✅ Implement separate repositories for MySQL and PostgreSQL +- ✅ Use `database.GetTx(ctx, r.db)` to get querier (supports transactions) +- ✅ Use numbered placeholders for PostgreSQL (`$1, $2`) and `?` for MySQL +- ✅ Transform infrastructure errors to domain errors + +**Example**: +```go +func (r *PostgreSQLUserRepository) Create(ctx context.Context, user *domain.User) error { + querier := database.GetTx(ctx, r.db) + + query := `INSERT INTO users (id, name, email, password, created_at, updated_at) + VALUES ($1, $2, $3, $4, NOW(), NOW())` + + _, err := querier.ExecContext(ctx, query, user.ID, user.Name, user.Email, user.Password) + if err != nil { + if isPostgreSQLUniqueViolation(err) { + return domain.ErrUserAlreadyExists + } + return apperrors.Wrap(err, "failed to create user") + } + return nil +} +``` + +### Unique Constraint Violations + +Check for database-specific error patterns: + +**PostgreSQL**: +```go +func isPostgreSQLUniqueViolation(err error) bool { + return strings.Contains(err.Error(), "duplicate key") || + strings.Contains(err.Error(), "unique constraint") +} +``` + +**MySQL**: +```go +func isMySQLUniqueViolation(err error) bool { + return strings.Contains(err.Error(), "Error 1062") || + strings.Contains(err.Error(), "Duplicate entry") +} +``` + +## ✅ Validation Patterns + +### Using jellydator/validation + +```go +import ( + validation "github.com/jellydator/validation" + appValidation "github.com/allisson/go-project-template/internal/validation" +) + +func (r *RegisterUserRequest) Validate() error { + err := validation.ValidateStruct(r, + validation.Field(&r.Name, + validation.Required.Error("name is required"), + appValidation.NotBlank, + validation.Length(1, 255), + ), + validation.Field(&r.Email, + validation.Required.Error("email is required"), + appValidation.Email, + ), + validation.Field(&r.Password, + validation.Required.Error("password is required"), + appValidation.PasswordStrength{ + MinLength: 8, + RequireUpper: true, + RequireLower: true, + RequireNumber: true, + RequireSpecial: true, + }, + ), + ) + return appValidation.WrapValidationError(err) +} +``` + +### Custom Validation Rules + +The project provides reusable validation rules in `internal/validation`: + +- `Email` - Email format validation +- `NotBlank` - Not empty after trimming +- `NoWhitespace` - No leading/trailing whitespace +- `PasswordStrength` - Configurable password requirements + +## 🆔 Working with UUIDs + +### Generating UUIDs + +Always use UUIDv7 for primary keys: + +```go +import "github.com/google/uuid" + +user := &domain.User{ + ID: uuid.Must(uuid.NewV7()), + Name: input.Name, + Email: input.Email, + Password: hashedPassword, +} +``` + +### Database Storage + +**PostgreSQL** - Native UUID type: +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY, + name VARCHAR(255) NOT NULL, + email VARCHAR(255) UNIQUE NOT NULL +); +``` + +**MySQL** - BINARY(16) with marshal/unmarshal: +```sql +CREATE TABLE users ( + id BINARY(16) PRIMARY KEY, + name VARCHAR(255) NOT NULL, + email VARCHAR(255) UNIQUE NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +``` + +```go +// MySQL requires UUID marshaling +uuidBytes, err := user.ID.MarshalBinary() +if err != nil { + return apperrors.Wrap(err, "failed to marshal UUID") +} +_, err = querier.ExecContext(ctx, query, uuidBytes, user.Name, user.Email) +``` + +## 🔄 Transaction Management + +### Using TxManager + +```go +err := uc.txManager.WithTx(ctx, func(ctx context.Context) error { + // All operations in this function share the same transaction + + if err := uc.userRepo.Create(ctx, user); err != nil { + return err // Transaction will rollback + } + + if err := uc.outboxRepo.Create(ctx, event); err != nil { + return err // Transaction will rollback + } + + return nil // Transaction will commit +}) +``` + +### Repository Support for Transactions + +Repositories automatically use transactions when available: + +```go +func (r *PostgreSQLUserRepository) Create(ctx context.Context, user *domain.User) error { + // GetTx returns transaction if present in context, otherwise returns DB + querier := database.GetTx(ctx, r.db) + + _, err := querier.ExecContext(ctx, query, args...) + return err +} +``` + +## 📦 Dependency Injection + +### Adding Components to DI Container + +When adding new components to `internal/app/di.go`: + +1. **Add fields** to Container struct: +```go +type Container struct { + // ... existing fields + productRepo productUsecase.ProductRepository + productUseCase productUsecase.UseCase // Interface! + productRepoInit sync.Once + productUseCaseInit sync.Once +} +``` + +2. **Add getter methods** with lazy initialization: +```go +func (c *Container) ProductRepository() (productUsecase.ProductRepository, error) { + var err error + c.productRepoInit.Do(func() { + c.productRepo, err = c.initProductRepository() + if err != nil { + c.initErrors["productRepo"] = err + } + }) + if err != nil { + return nil, err + } + if existingErr, ok := c.initErrors["productRepo"]; ok { + return nil, existingErr + } + return c.productRepo, nil +} +``` + +3. **Add initialization methods**: +```go +func (c *Container) initProductRepository() (productUsecase.ProductRepository, error) { + db, err := c.DB() + if err != nil { + return nil, fmt.Errorf("failed to get database: %w", err) + } + + switch c.config.DBDriver { + case "mysql": + return productRepository.NewMySQLProductRepository(db), nil + case "postgres": + return productRepository.NewPostgreSQLProductRepository(db), nil + default: + return nil, fmt.Errorf("unsupported database driver: %s", c.config.DBDriver) + } +} +``` + +## 🚀 Development Workflow + +### 1. Create a New Feature Branch + +```bash +git checkout -b feature/your-feature-name +``` + +### 2. Make Changes + +- Write code following the style guidelines +- Add tests for new functionality +- Update documentation as needed + +### 3. Run Tests + +```bash +make test-db-up # Start test databases +make test # Run tests +make test-db-down # Stop test databases +``` + +Or use the combined command: +```bash +make test-with-db # Start databases, run tests, stop databases +``` + +### 4. Run Linter + +```bash +make lint +``` + +### 5. Build the Application + +```bash +make build +``` + +### 6. Commit Your Changes + +```bash +git add . +git commit -m "feat: add new feature" +``` + +**Commit Message Format**: +- `feat:` - New feature +- `fix:` - Bug fix +- `docs:` - Documentation changes +- `refactor:` - Code refactoring +- `test:` - Test updates +- `chore:` - Maintenance tasks + +### 7. Push and Create Pull Request + +```bash +git push origin feature/your-feature-name +``` + +Then create a pull request on GitHub. + +## 🔧 Common Development Tasks + +### Add a New Endpoint + +1. Define request/response DTOs in `internal/{domain}/http/dto/` +2. Add validation to request DTO +3. Create handler in `internal/{domain}/http/{domain}_handler.go` +4. Register route in `internal/http/server.go` +5. Add tests + +### Add a New Use Case + +1. Define use case method in interface (`internal/{domain}/usecase/`) +2. Implement business logic +3. Add validation +4. Handle transactions if needed +5. Add tests + +### Create a Database Migration + +1. Create `000xxx_description.up.sql` in `migrations/postgresql/` or `migrations/mysql/` +2. Create corresponding `000xxx_description.down.sql` +3. Run migrations: `make run-migrate` + +### Add a Custom Validation Rule + +1. Add rule to `internal/validation/rules.go` +2. Implement `Validate(value interface{}) error` method +3. Add tests in `internal/validation/rules_test.go` +4. Use in DTOs + +## 📚 Useful Resources + +- [Effective Go](https://golang.org/doc/effective_go) +- [Go Code Review Comments](https://github.com/golang/go/wiki/CodeReviewComments) +- [Clean Architecture](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html) +- [Domain-Driven Design](https://martinfowler.com/bliki/DomainDrivenDesign.html) + +## 🐛 Debugging Tips + +### Enable Debug Logging + +Set `LOG_LEVEL=debug` in your `.env` file: + +```bash +LOG_LEVEL=debug +``` + +### Database Query Debugging + +Add logging in repositories to see SQL queries: + +```go +log.Printf("Executing query: %s with args: %v", query, args) +``` + +### Use Delve Debugger + +Install Delve: +```bash +go install github.com/go-delve/delve/cmd/dlv@latest +``` + +Run with debugger: +```bash +dlv debug ./cmd/app -- server +``` + +### Check Health Endpoints + +```bash +curl http://localhost:8080/health # Application health +curl http://localhost:8080/ready # Database connectivity +``` diff --git a/docs/error-handling.md b/docs/error-handling.md new file mode 100644 index 0000000..d2eb3cf --- /dev/null +++ b/docs/error-handling.md @@ -0,0 +1,503 @@ +# ⚠️ Error Handling + +This guide explains the standardized error handling system used in this Go project. + +## 🎯 Philosophy + +The error handling system is designed to: + +- 🎯 **Express business intent** rather than expose infrastructure details +- 🔒 **Prevent information leaks** (database errors never exposed to API clients) +- ✅ **Maintain consistency** (same error always maps to same HTTP status) +- 🔍 **Enable type-safe error checking** using `errors.Is()` +- 📊 **Provide structured responses** with consistent JSON format + +## 📚 Standard Domain Errors + +The project defines standard domain errors in `internal/errors/errors.go`: + +```go +package errors + +import "errors" + +var ( + ErrNotFound = errors.New("not found") // 404 Not Found + ErrConflict = errors.New("conflict") // 409 Conflict + ErrInvalidInput = errors.New("invalid input") // 422 Unprocessable Entity + ErrUnauthorized = errors.New("unauthorized") // 401 Unauthorized + ErrForbidden = errors.New("forbidden") // 403 Forbidden +) + +// Wrap wraps an error with additional context +func Wrap(err error, message string) error { + return fmt.Errorf("%s: %w", message, err) +} +``` + +## 🏷️ Domain-Specific Errors + +Each domain defines its own specific errors by **wrapping** the standard errors: + +```go +// internal/user/domain/user.go +package domain + +import ( + apperrors "github.com/allisson/go-project-template/internal/errors" +) + +var ( + ErrUserNotFound = apperrors.Wrap(apperrors.ErrNotFound, "user not found") + ErrUserAlreadyExists = apperrors.Wrap(apperrors.ErrConflict, "user already exists") + ErrInvalidEmail = apperrors.Wrap(apperrors.ErrInvalidInput, "invalid email format") + ErrWeakPassword = apperrors.Wrap(apperrors.ErrInvalidInput, "password does not meet strength requirements") +) +``` + +**Key Points**: +- ✅ Wrap standard errors with domain-specific context +- ✅ Use descriptive error messages that explain the business problem +- ✅ Group related errors in the domain package + +## 🔄 Error Flow Through Layers + +Errors flow through the application layers, being transformed from infrastructure concerns to domain concepts: + +``` +Infrastructure Error (sql.ErrNoRows) + ↓ + [Repository Layer] + ↓ +Domain Error (ErrUserNotFound) + ↓ + [Use Case Layer] + ↓ +Domain Error (unchanged) + ↓ + [HTTP Handler Layer] + ↓ +HTTP Response (404 Not Found) +``` + +### 1️⃣ Repository Layer + +**Responsibility**: Transform infrastructure errors to domain errors + +```go +// 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 infrastructure → domain + } + return nil, apperrors.Wrap(err, "failed to get user by id") + } + return &user, nil +} +``` + +**Unique Constraint Violations**: + +```go +func (r *PostgreSQLUserRepository) Create(ctx context.Context, user *domain.User) error { + querier := database.GetTx(ctx, r.db) + + query := `INSERT INTO users (id, name, email, password, created_at, updated_at) + VALUES ($1, $2, $3, $4, NOW(), NOW())` + + _, err := querier.ExecContext(ctx, query, user.ID, user.Name, user.Email, user.Password) + if err != nil { + if isPostgreSQLUniqueViolation(err) { + return domain.ErrUserAlreadyExists // ✅ Transform unique violation → domain error + } + return apperrors.Wrap(err, "failed to create user") + } + return nil +} + +func isPostgreSQLUniqueViolation(err error) bool { + return strings.Contains(err.Error(), "duplicate key") || + strings.Contains(err.Error(), "unique constraint") +} +``` + +**MySQL Example**: + +```go +func isMySQLUniqueViolation(err error) bool { + return strings.Contains(err.Error(), "Error 1062") || + strings.Contains(err.Error(), "Duplicate entry") +} +``` + +### 2️⃣ Use Case Layer + +**Responsibility**: Return domain errors directly (don't wrap again) + +```go +// internal/user/usecase/user_usecase.go +func (uc *UserUseCase) RegisterUser(ctx context.Context, input RegisterUserInput) (*domain.User, error) { + // Validate input + if err := input.Validate(); err != nil { + return nil, err // ✅ Return validation error directly + } + + // Check if user exists + existingUser, err := uc.userRepo.GetByEmail(ctx, input.Email) + if err != nil && !errors.Is(err, domain.ErrUserNotFound) { + return nil, err // ✅ Return unexpected errors directly + } + if existingUser != nil { + return nil, domain.ErrUserAlreadyExists // ✅ Return domain error directly + } + + // Hash password + hashedPassword, err := uc.passwordHasher.Hash([]byte(input.Password)) + if err != nil { + return nil, apperrors.Wrap(err, "failed to hash password") + } + + // Create user + user := &domain.User{ + ID: uuid.Must(uuid.NewV7()), + Name: input.Name, + Email: input.Email, + Password: string(hashedPassword), + } + + 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 + } + return uc.outboxRepo.Create(ctx, event) + }) + + return user, err // ✅ Return error unchanged +} +``` + +**Key Points**: +- ✅ **Never wrap domain errors** in the use case layer +- ✅ **Return domain errors directly** to maintain error chain +- ✅ **Use `errors.Is()`** to check for specific error types + +### 3️⃣ HTTP Handler Layer + +**Responsibility**: Map domain errors to HTTP responses + +```go +// internal/user/http/user_handler.go +func (h *UserHandler) RegisterUser(w http.ResponseWriter, r *http.Request) { + var req dto.RegisterUserRequest + + // Decode request + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + httputil.HandleValidationError(w, err, h.logger) // 400 Bad Request + return + } + + // Validate request + if err := req.Validate(); err != nil { + httputil.HandleError(w, err, h.logger) // 422 Unprocessable Entity + return + } + + // Execute use case + input := dto.ToRegisterUserInput(req) + user, err := h.userUseCase.RegisterUser(r.Context(), input) + if err != nil { + httputil.HandleError(w, err, h.logger) // ✅ Auto-maps to HTTP status + return + } + + // Success response + response := dto.ToUserResponse(user) + httputil.MakeJSONResponse(w, http.StatusCreated, response) +} +``` + +## 🌐 HTTP Error Mapping + +The `httputil.HandleError()` function automatically maps domain errors to HTTP status codes: + +```go +// internal/httputil/response.go +func HandleError(w http.ResponseWriter, err error, logger *slog.Logger) { + var statusCode int + var errorCode string + var message string + + switch { + case errors.Is(err, apperrors.ErrNotFound): + statusCode = http.StatusNotFound + errorCode = "not_found" + message = "The requested resource was not found" + case errors.Is(err, apperrors.ErrConflict): + statusCode = http.StatusConflict + errorCode = "conflict" + message = "A conflict occurred with existing data" + case errors.Is(err, apperrors.ErrInvalidInput): + statusCode = http.StatusUnprocessableEntity + errorCode = "invalid_input" + message = err.Error() // Include detailed validation message + case errors.Is(err, apperrors.ErrUnauthorized): + statusCode = http.StatusUnauthorized + errorCode = "unauthorized" + message = "Authentication is required" + case errors.Is(err, apperrors.ErrForbidden): + statusCode = http.StatusForbidden + errorCode = "forbidden" + message = "You don't have permission to access this resource" + default: + statusCode = http.StatusInternalServerError + errorCode = "internal_error" + message = "An internal error occurred" + } + + // Log error with context + logger.Error("request error", + "error", err.Error(), + "status_code", statusCode, + "error_code", errorCode, + ) + + // Send JSON response + MakeJSONResponse(w, statusCode, map[string]string{ + "error": errorCode, + "message": message, + }) +} +``` + +### Error to HTTP Status Mapping Table + +| Domain Error | HTTP Status | Error Code | Use Case | +|--------------|-------------|------------|----------| +| `ErrNotFound` | 404 | `not_found` | Resource doesn't exist | +| `ErrConflict` | 409 | `conflict` | Duplicate or conflicting data | +| `ErrInvalidInput` | 422 | `invalid_input` | Validation failures | +| `ErrUnauthorized` | 401 | `unauthorized` | Authentication required | +| `ErrForbidden` | 403 | `forbidden` | Permission denied | +| Unknown | 500 | `internal_error` | Unexpected errors | + +## 📋 Error Response Format + +All error responses follow a consistent JSON structure: + +```json +{ + "error": "error_code", + "message": "Human-readable error message" +} +``` + +### Examples + +**404 Not Found**: +```json +{ + "error": "not_found", + "message": "The requested resource was not found" +} +``` + +**409 Conflict**: +```json +{ + "error": "conflict", + "message": "A conflict occurred with existing data" +} +``` + +**422 Validation Error**: +```json +{ + "error": "invalid_input", + "message": "email: must be a valid email address; password: password must contain at least one uppercase letter." +} +``` + +**500 Internal Server Error**: +```json +{ + "error": "internal_error", + "message": "An internal error occurred" +} +``` + +## ✅ Validation Errors + +Validation errors are treated as `ErrInvalidInput` and automatically return 422 status. + +### DTO Validation + +```go +// internal/user/http/dto/request.go +func (r *RegisterUserRequest) Validate() error { + err := validation.ValidateStruct(r, + validation.Field(&r.Name, + validation.Required.Error("name is required"), + appValidation.NotBlank, + validation.Length(1, 255), + ), + validation.Field(&r.Email, + validation.Required.Error("email is required"), + appValidation.Email, + ), + validation.Field(&r.Password, + validation.Required.Error("password is required"), + appValidation.PasswordStrength{ + MinLength: 8, + RequireUpper: true, + RequireLower: true, + RequireNumber: true, + RequireSpecial: true, + }, + ), + ) + return appValidation.WrapValidationError(err) // ✅ Wraps as ErrInvalidInput +} +``` + +### WrapValidationError + +The `WrapValidationError` function converts validation errors to `ErrInvalidInput`: + +```go +// internal/validation/rules.go +func WrapValidationError(err error) error { + if err == nil { + return nil + } + return apperrors.Wrap(apperrors.ErrInvalidInput, err.Error()) +} +``` + +## 🔍 Checking Errors + +Use `errors.Is()` to check for specific error types: + +```go +user, err := uc.userRepo.GetByEmail(ctx, email) +if err != nil { + if errors.Is(err, domain.ErrUserNotFound) { + // Handle not found case + return nil, domain.ErrInvalidCredentials + } + // Handle other errors + return nil, err +} +``` + +**DO ✅**: +```go +if errors.Is(err, domain.ErrUserNotFound) { + // Handle error +} +``` + +**DON'T ❌**: +```go +if err == domain.ErrUserNotFound { // Won't work with wrapped errors + // Handle error +} +``` + +## ➕ Adding Errors to New Domains + +When creating a new domain (e.g., `product`), define domain-specific errors: + +```go +// internal/product/domain/product.go +package domain + +import ( + apperrors "github.com/allisson/go-project-template/internal/errors" +) + +var ( + ErrProductNotFound = apperrors.Wrap(apperrors.ErrNotFound, "product not found") + ErrInsufficientStock = apperrors.Wrap(apperrors.ErrConflict, "insufficient stock") + ErrInvalidPrice = apperrors.Wrap(apperrors.ErrInvalidInput, "invalid price") + ErrInvalidQuantity = apperrors.Wrap(apperrors.ErrInvalidInput, "quantity must be positive") +) +``` + +Then use `httputil.HandleError()` in your HTTP handlers for automatic mapping - no additional code needed! + +## 🎯 Best Practices + +### DO ✅ + +- ✅ **Define domain-specific errors** by wrapping standard errors +- ✅ **Transform infrastructure errors** in repository layer +- ✅ **Return domain errors directly** from use case layer +- ✅ **Use `errors.Is()`** for type-safe error checking +- ✅ **Use `httputil.HandleError()`** for consistent HTTP error responses +- ✅ **Log errors with context** before responding to client +- ✅ **Include descriptive messages** in domain errors +- ✅ **Keep error messages user-friendly** (no stack traces or internal details) + +### DON'T ❌ + +- ❌ **Don't expose infrastructure errors** (like `sql.ErrNoRows`) to API clients +- ❌ **Don't wrap domain errors** multiple times in use case layer +- ❌ **Don't compare errors with `==`** (use `errors.Is()` instead) +- ❌ **Don't return different HTTP status codes** for the same domain error +- ❌ **Don't include sensitive information** in error messages +- ❌ **Don't return stack traces** to API clients +- ❌ **Don't create new error types** when standard errors suffice + +## 🔒 Security Considerations + +### Information Disclosure + +**DO ✅**: +```go +// Return generic error message +return domain.ErrUserNotFound +``` + +**DON'T ❌**: +```go +// Reveals internal database structure +return fmt.Errorf("no row found in users table for id=%s", id) +``` + +### Error Logging + +Log detailed errors on the server, but return generic messages to clients: + +```go +func HandleError(w http.ResponseWriter, err error, logger *slog.Logger) { + // Log detailed error server-side + logger.Error("request error", + "error", err.Error(), + "stack", getStackTrace(), + ) + + // Return generic message to client + MakeJSONResponse(w, statusCode, map[string]string{ + "error": errorCode, + "message": genericMessage, // No sensitive details + }) +} +``` + +## 📊 Benefits Summary + +1. 🎯 **No Infrastructure Leaks** - Database errors never exposed to API clients +2. 💼 **Business Intent** - Errors express domain concepts (e.g., `ErrUserNotFound` vs `sql.ErrNoRows`) +3. ✅ **Consistent HTTP Mapping** - Same domain error always maps to same HTTP status +4. 🔒 **Type-Safe** - Use `errors.Is()` to check for specific error types +5. 📋 **Structured Responses** - All errors return consistent JSON format +6. 📊 **Centralized Logging** - All errors logged with full context before responding +7. 🛡️ **Security** - Sensitive information never leaked in error messages diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..920c8b9 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,348 @@ +# 🚀 Getting Started + +This guide will help you set up and run the Go project template on your local machine. + +## 📋 Prerequisites + +Before you begin, ensure you have the following installed: + +- ✅ **Go 1.25 or higher** - [Download Go](https://golang.org/dl/) +- ✅ **PostgreSQL 12+** or **MySQL 8.0+** - For development +- ✅ **Docker and Docker Compose** - For testing and optional development +- ✅ **Make** (optional) - For convenience commands + +## 📥 Installation + +### 1. Clone the repository + +```bash +git clone https://github.com/allisson/go-project-template.git +cd go-project-template +``` + +### 2. Customize the module path + +After cloning, update the import paths to match your project. + +#### Option 1: Using find and sed (Linux/macOS) + +```bash +# Replace with your actual module path +NEW_MODULE="github.com/yourname/yourproject" + +# Update go.mod +sed -i "s|github.com/allisson/go-project-template|$NEW_MODULE|g" go.mod + +# Update all Go files +find . -type f -name "*.go" -exec sed -i "s|github.com/allisson/go-project-template|$NEW_MODULE|g" {} + +``` + +#### Option 2: Using PowerShell (Windows) + +```powershell +# Replace with your actual module path +$NEW_MODULE = "github.com/yourname/yourproject" + +# Update go.mod +(Get-Content go.mod) -replace 'github.com/allisson/go-project-template', $NEW_MODULE | Set-Content go.mod + +# Update all Go files +Get-ChildItem -Recurse -Filter *.go | ForEach-Object { + (Get-Content $_.FullName) -replace 'github.com/allisson/go-project-template', $NEW_MODULE | Set-Content $_.FullName +} +``` + +#### Option 3: Manually + +1. Update the module name in `go.mod` +2. Search and replace `github.com/allisson/go-project-template` with your module path in all `.go` files + +After updating, verify the changes and tidy dependencies: + +```bash +go mod tidy +``` + +**Important**: Also update the `.golangci.yml` file to match your new module path: + +```yaml +formatters: + settings: + goimports: + local-prefixes: + - github.com/yourname/yourproject # Update this line +``` + +This ensures the linter correctly groups your local imports. + +### 3. Install dependencies + +```bash +go mod download +``` + +## ⚙️ Configuration + +### Environment Variables + +The application automatically loads environment variables from a `.env` file. Create a `.env` file in your project root: + +```bash +# Database configuration +DB_DRIVER=postgres # or mysql +DB_CONNECTION_STRING=postgres://user:password@localhost:5432/mydb?sslmode=disable +DB_MAX_OPEN_CONNECTIONS=25 +DB_MAX_IDLE_CONNECTIONS=5 +DB_CONN_MAX_LIFETIME=5 + +# Server configuration +SERVER_HOST=0.0.0.0 +SERVER_PORT=8080 + +# Logging +LOG_LEVEL=info + +# Worker configuration +WORKER_INTERVAL=5 +WORKER_BATCH_SIZE=10 +WORKER_MAX_RETRIES=3 +WORKER_RETRY_INTERVAL=1 +``` + +**Note**: The application searches for the `.env` file recursively from the current working directory up to the root directory. This allows you to run the application from any subdirectory. + +### Configuration Options + +| Variable | Description | Default | +|----------|-------------|---------| +| `SERVER_HOST` | HTTP server host | `0.0.0.0` | +| `SERVER_PORT` | HTTP server port | `8080` | +| `DB_DRIVER` | Database driver (`postgres`/`mysql`) | `postgres` | +| `DB_CONNECTION_STRING` | Database connection string | - | +| `DB_MAX_OPEN_CONNECTIONS` | Max open connections | `25` | +| `DB_MAX_IDLE_CONNECTIONS` | Max idle connections | `5` | +| `DB_CONN_MAX_LIFETIME` | Connection max lifetime (minutes) | `5` | +| `LOG_LEVEL` | Log level (`debug`/`info`/`warn`/`error`) | `info` | +| `WORKER_INTERVAL` | Worker poll interval (seconds) | `5` | +| `WORKER_BATCH_SIZE` | Events to process per batch | `10` | +| `WORKER_MAX_RETRIES` | Max retry attempts | `3` | +| `WORKER_RETRY_INTERVAL` | Retry interval (seconds) | `1` | + +## 🗄️ Database Setup + +### Using Docker (Recommended) + +#### PostgreSQL + +```bash +make dev-postgres +``` + +This starts PostgreSQL on port `5432` with the following credentials: +- **User**: `postgres` +- **Password**: `postgres` +- **Database**: `mydb` + +#### MySQL + +```bash +make dev-mysql +``` + +This starts MySQL on port `3306` with the following credentials: +- **User**: `root` +- **Password**: `root` +- **Database**: `mydb` + +### Using Local Installation + +If you have PostgreSQL or MySQL installed locally, create a database and update your `.env` file with the appropriate connection string. + +**PostgreSQL**: +```sql +CREATE DATABASE mydb; +``` + +**MySQL**: +```sql +CREATE DATABASE mydb CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +``` + +## 🔄 Database Migrations + +Run database migrations to create the required tables: + +```bash +make run-migrate +``` + +This command: +1. Connects to your database using the `DB_CONNECTION_STRING` +2. Runs all pending migrations from the appropriate directory (`migrations/postgresql` or `migrations/mysql`) +3. Creates the `users` and `outbox_events` tables + +## ▶️ Running the Application + +### Start the HTTP Server + +```bash +make run-server +``` + +The server will be available at http://localhost:8080 + +**Health Check**: +```bash +curl http://localhost:8080/health +``` + +**Readiness Check**: +```bash +curl http://localhost:8080/ready +``` + +### Start the Background Worker + +In another terminal, start the worker process: + +```bash +make run-worker +``` + +The worker processes outbox events from the database. + +## 🧪 Testing the API + +### Register a User + +```bash +curl -X POST http://localhost:8080/api/users \ + -H "Content-Type: application/json" \ + -d '{ + "name": "John Doe", + "email": "john@example.com", + "password": "SecurePass123!" + }' +``` + +**Success Response** (201 Created): +```json +{ + "id": "01936a99-8c2f-7890-b123-456789abcdef", + "name": "John Doe", + "email": "john@example.com", + "created_at": "2024-01-15T10:30:00Z", + "updated_at": "2024-01-15T10:30:00Z" +} +``` + +**Password Requirements**: +- ✅ Minimum 8 characters +- ✅ At least one uppercase letter +- ✅ At least one lowercase letter +- ✅ At least one number +- ✅ At least one special character + +**Validation Error Response** (422 Unprocessable Entity): +```json +{ + "error": "invalid_input", + "message": "email: must be a valid email address; password: password must contain at least one uppercase letter." +} +``` + +## 🐳 Docker Deployment + +### Build Docker Image + +```bash +make docker-build +``` + +### Run Server in Docker + +```bash +make docker-run-server +``` + +### Run Worker in Docker + +```bash +make docker-run-worker +``` + +### Run Migrations in Docker + +```bash +make docker-run-migrate +``` + +## 🔧 CLI Commands + +The binary supports three commands via `urfave/cli`: + +### Start HTTP Server +```bash +./bin/app server +``` + +### Run Database Migrations +```bash +./bin/app migrate +``` + +### Run Event Worker +```bash +./bin/app worker +``` + +## 📚 Next Steps + +Now that you have the application running, you can: + +- 📖 Learn about the [Architecture](architecture.md) +- 🛠️ Set up your [Development Environment](development.md) +- ✅ Learn about [Testing](testing.md) +- ⚠️ Understand [Error Handling](error-handling.md) +- ➕ Learn how to [Add New Domains](adding-domains.md) + +## 🆘 Troubleshooting + +### Database Connection Issues + +**Problem**: `failed to connect to database` + +**Solutions**: +- ✅ Verify database is running: `docker ps` (if using Docker) +- ✅ Check connection string in `.env` file +- ✅ Ensure database credentials are correct +- ✅ Check firewall settings + +### Port Already in Use + +**Problem**: `bind: address already in use` + +**Solutions**: +- ✅ Change `SERVER_PORT` in `.env` file +- ✅ Stop other services using the same port +- ✅ Use `lsof -i :8080` (Linux/macOS) or `netstat -ano | findstr :8080` (Windows) to find the process + +### Migration Failures + +**Problem**: `migration failed` + +**Solutions**: +- ✅ Check database connectivity +- ✅ Verify you're using the correct database driver (`postgres` or `mysql`) +- ✅ Ensure database user has sufficient permissions +- ✅ Check migration files for syntax errors + +### Module Import Errors + +**Problem**: `cannot find package` + +**Solutions**: +- ✅ Run `go mod tidy` to sync dependencies +- ✅ Verify you updated all import paths after cloning +- ✅ Check `.golangci.yml` has correct module path +- ✅ Clear Go cache: `go clean -modcache` diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..81af0a5 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,511 @@ +# 🧪 Testing Guide + +This guide covers testing strategies, best practices, and how to write effective tests for this Go project. + +## 🎯 Testing Philosophy + +The project uses **integration testing with real databases** instead of mocks for repository layer tests. + +**Why Real Databases?** +- ✅ **Accuracy** - Tests verify actual SQL queries and database behavior +- ✅ **Real Integration** - Catches database-specific issues (constraints, types, unique violations) +- ✅ **Production Parity** - Tests reflect real production scenarios +- ✅ **Less Maintenance** - No mock expectations to maintain or update +- ✅ **Confidence** - Full database integration coverage + +## 🏗️ Test Infrastructure + +### Test Databases + +Tests use Docker Compose to spin up isolated test databases: + +- **PostgreSQL**: `localhost:5433` (testuser/testpassword/testdb) +- **MySQL**: `localhost:3307` (testuser/testpassword/testdb) + +**Note**: Different ports from development (5432/3306) to avoid conflicts. + +### Test Utilities + +The `testutil` package (`internal/testutil/database.go`) provides helper functions: + +1. **`SetupPostgresDB(t)`** - Connect to PostgreSQL and run migrations +2. **`SetupMySQLDB(t)`** - Connect to MySQL and run migrations +3. **`CleanupPostgresDB(t, db)`** - Clean up PostgreSQL test data +4. **`CleanupMySQLDB(t, db)`** - Clean up MySQL test data +5. **`TeardownDB(t, db)`** - Close database connection + +## 🚀 Running Tests + +### Start Test Databases + +Before running tests, start the test databases: + +```bash +make test-db-up +``` + +This command: +- Starts PostgreSQL on port 5433 +- Starts MySQL on port 3307 +- Waits for databases to be healthy + +### Run All Tests + +```bash +make test +``` + +This runs all tests with coverage reporting. + +### Run Tests with Automatic Database Management + +```bash +make test-with-db +``` + +This command: +1. Starts test databases +2. Runs all tests +3. Stops test databases + +Perfect for CI/CD environments! + +### Run Tests with Coverage + +```bash +make test-coverage +``` + +This generates an HTML coverage report and opens it in your browser. + +### Stop Test Databases + +```bash +make test-db-down +``` + +### Run Tests for Specific Package + +```bash +# Run all tests in a package +go test -v ./internal/user/repository + +# Run a specific test +go test -v ./internal/user/repository -run TestPostgreSQLUserRepository_Create + +# Run with race detection +go test -v -race ./internal/user/repository +``` + +## 📝 Writing Tests + +### Repository Tests + +Repository tests use real databases to verify SQL queries and database interactions. + +**Test Structure**: + +```go +package repository + +import ( + "context" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + + "github.com/allisson/go-project-template/internal/testutil" + "github.com/allisson/go-project-template/internal/user/domain" +) + +func TestPostgreSQLUserRepository_Create(t *testing.T) { + // Setup: Connect to database and run migrations + db := testutil.SetupPostgresDB(t) + defer testutil.TeardownDB(t, db) // Close connection + defer testutil.CleanupPostgresDB(t, db) // Clean up test data + + // Create repository + repo := NewPostgreSQLUserRepository(db) + ctx := context.Background() + + // Prepare test data + user := &domain.User{ + ID: uuid.Must(uuid.NewV7()), + Name: "John Doe", + Email: "john@example.com", + Password: "hashed_password", + } + + // Execute test + err := repo.Create(ctx, user) + + // Assert results + assert.NoError(t, err) + + // Verify by querying the real database + createdUser, err := repo.GetByID(ctx, user.ID) + assert.NoError(t, err) + assert.Equal(t, user.Name, createdUser.Name) + assert.Equal(t, user.Email, createdUser.Email) +} +``` + +**Key Points**: +- 🔄 Use `defer` for cleanup (connection and data) +- 🧹 Clean up test data to prevent test pollution +- ✅ Verify operations by querying the database +- 🎯 Test one thing per test function + +### Testing Error Cases + +```go +func TestPostgreSQLUserRepository_GetByID_NotFound(t *testing.T) { + db := testutil.SetupPostgresDB(t) + defer testutil.TeardownDB(t, db) + defer testutil.CleanupPostgresDB(t, db) + + repo := NewPostgreSQLUserRepository(db) + ctx := context.Background() + + // Try to get non-existent user + nonExistentID := uuid.Must(uuid.NewV7()) + user, err := repo.GetByID(ctx, nonExistentID) + + // Verify error handling + assert.Error(t, err) + assert.Nil(t, user) + assert.ErrorIs(t, err, domain.ErrUserNotFound) +} +``` + +### Testing Unique Constraints + +```go +func TestPostgreSQLUserRepository_Create_DuplicateEmail(t *testing.T) { + db := testutil.SetupPostgresDB(t) + defer testutil.TeardownDB(t, db) + defer testutil.CleanupPostgresDB(t, db) + + repo := NewPostgreSQLUserRepository(db) + ctx := context.Background() + + // Create first user + user1 := &domain.User{ + ID: uuid.Must(uuid.NewV7()), + Name: "John Doe", + Email: "john@example.com", + Password: "password1", + } + err := repo.Create(ctx, user1) + assert.NoError(t, err) + + // Try to create second user with same email + user2 := &domain.User{ + ID: uuid.Must(uuid.NewV7()), + Name: "Jane Doe", + Email: "john@example.com", // Duplicate email + Password: "password2", + } + err = repo.Create(ctx, user2) + + // Verify unique constraint error + assert.Error(t, err) + assert.ErrorIs(t, err, domain.ErrUserAlreadyExists) +} +``` + +### Use Case Tests + +Use case tests can use real repositories or mocks depending on the scenario. + +**Testing with Real Repository**: + +```go +func TestUserUseCase_RegisterUser(t *testing.T) { + db := testutil.SetupPostgresDB(t) + defer testutil.TeardownDB(t, db) + defer testutil.CleanupPostgresDB(t, db) + + // Setup dependencies + userRepo := repository.NewPostgreSQLUserRepository(db) + outboxRepo := outboxRepository.NewPostgreSQLOutboxRepository(db) + txManager := database.NewTxManager(db) + passwordHasher := pwdhash.NewArgon2Hasher(pwdhash.Argon2Config{}) + + // Create use case + uc := usecase.NewUserUseCase(txManager, userRepo, outboxRepo, passwordHasher) + ctx := context.Background() + + // Prepare input + input := usecase.RegisterUserInput{ + Name: "John Doe", + Email: "john@example.com", + Password: "SecurePass123!", + } + + // Execute + user, err := uc.RegisterUser(ctx, input) + + // Assert + assert.NoError(t, err) + assert.NotNil(t, user) + assert.Equal(t, input.Name, user.Name) + assert.Equal(t, input.Email, user.Email) + + // Verify user was created in database + createdUser, err := userRepo.GetByEmail(ctx, input.Email) + assert.NoError(t, err) + assert.Equal(t, user.ID, createdUser.ID) + + // Verify outbox event was created + events, err := outboxRepo.GetPending(ctx, 10) + assert.NoError(t, err) + assert.Len(t, events, 1) + assert.Equal(t, "user.created", events[0].EventType) +} +``` + +### HTTP Handler Tests + +HTTP handler tests verify the presentation layer. + +```go +func TestUserHandler_RegisterUser(t *testing.T) { + db := testutil.SetupPostgresDB(t) + defer testutil.TeardownDB(t, db) + defer testutil.CleanupPostgresDB(t, db) + + // Setup dependencies + userRepo := repository.NewPostgreSQLUserRepository(db) + outboxRepo := outboxRepository.NewPostgreSQLOutboxRepository(db) + txManager := database.NewTxManager(db) + passwordHasher := pwdhash.NewArgon2Hasher(pwdhash.Argon2Config{}) + uc := usecase.NewUserUseCase(txManager, userRepo, outboxRepo, passwordHasher) + logger := slog.New(slog.NewJSONHandler(io.Discard, nil)) + handler := http.NewUserHandler(uc, logger) + + // Prepare request + reqBody := `{ + "name": "John Doe", + "email": "john@example.com", + "password": "SecurePass123!" + }` + req := httptest.NewRequest("POST", "/api/users", strings.NewReader(reqBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + // Execute + handler.RegisterUser(w, req) + + // Assert response + assert.Equal(t, http.StatusCreated, w.Code) + + var response dto.UserResponse + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Equal(t, "John Doe", response.Name) + assert.Equal(t, "john@example.com", response.Email) +} +``` + +### Testing Validation Errors + +```go +func TestUserHandler_RegisterUser_ValidationError(t *testing.T) { + // Setup (minimal dependencies for validation test) + logger := slog.New(slog.NewJSONHandler(io.Discard, nil)) + handler := http.NewUserHandler(nil, logger) + + // Invalid request (missing required fields) + reqBody := `{ + "name": "", + "email": "invalid-email", + "password": "weak" + }` + req := httptest.NewRequest("POST", "/api/users", strings.NewReader(reqBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + // Execute + handler.RegisterUser(w, req) + + // Assert validation error response + assert.Equal(t, http.StatusUnprocessableEntity, w.Code) + + var errorResponse map[string]string + err := json.Unmarshal(w.Body.Bytes(), &errorResponse) + assert.NoError(t, err) + assert.Equal(t, "invalid_input", errorResponse["error"]) + assert.Contains(t, errorResponse["message"], "name") + assert.Contains(t, errorResponse["message"], "email") + assert.Contains(t, errorResponse["message"], "password") +} +``` + +## 📊 Test Coverage + +### Viewing Coverage Reports + +```bash +# Generate and view HTML coverage report +make test-coverage +``` + +This opens an HTML report in your browser showing: +- 📈 Overall coverage percentage +- 📁 Coverage by package +- 📄 Line-by-line coverage highlighting + +### Coverage Goals + +Aim for these coverage targets: +- **Domain Layer**: 90%+ (core business logic) +- **Use Case Layer**: 85%+ (business orchestration) +- **Repository Layer**: 90%+ (data access) +- **HTTP Layer**: 80%+ (handlers and DTOs) + +### Checking Coverage from Command Line + +```bash +# Run tests with coverage +go test -cover ./... + +# Generate detailed coverage report +go test -coverprofile=coverage.out ./... +go tool cover -func=coverage.out + +# View coverage in browser +go tool cover -html=coverage.out +``` + +## 🔍 Test Naming Conventions + +Use descriptive test names that follow this pattern: + +``` +Test{Type}_{Method}_{Scenario} +``` + +**Examples**: +- `TestPostgreSQLUserRepository_Create` +- `TestPostgreSQLUserRepository_GetByID_NotFound` +- `TestUserUseCase_RegisterUser_DuplicateEmail` +- `TestUserHandler_RegisterUser_ValidationError` + +**Benefits**: +- ✅ Easy to identify what's being tested +- ✅ Clear understanding of test scenarios +- ✅ Better test failure messages + +## 🏃 CI/CD Testing + +### GitHub Actions + +The project includes a GitHub Actions workflow (`.github/workflows/ci.yml`) that: + +1. ✅ Starts PostgreSQL (port 5433) and MySQL (port 3307) containers +2. ✅ Waits for both databases to be healthy +3. ✅ Runs all tests with race detection +4. ✅ Generates coverage reports +5. ✅ Uploads coverage to Codecov + +**CI Configuration**: +- Same database credentials as local tests (testuser/testpassword/testdb) +- Same port mappings as Docker Compose (5433 for Postgres, 3307 for MySQL) +- Runs on every push to `main` and all pull requests +- All tests must pass before merging + +### Running Tests Like CI Locally + +```bash +# Exact same command as CI +make test-with-db +``` + +This ensures consistency between local development and CI environments. + +## 🛠️ Debugging Tests + +### Run Tests with Verbose Output + +```bash +go test -v ./internal/user/repository +``` + +### Run Single Test + +```bash +go test -v ./internal/user/repository -run TestPostgreSQLUserRepository_Create +``` + +### Enable Race Detection + +```bash +go test -race ./... +``` + +### Print Test Output + +```go +func TestSomething(t *testing.T) { + t.Logf("Debug info: %v", someValue) + + // Or use fmt for immediate output + fmt.Printf("Debug info: %v\n", someValue) +} +``` + +### Check Database State During Tests + +```go +func TestUserRepository_Create(t *testing.T) { + db := testutil.SetupPostgresDB(t) + defer testutil.TeardownDB(t, db) + defer testutil.CleanupPostgresDB(t, db) + + repo := NewPostgreSQLUserRepository(db) + + // Create user + err := repo.Create(ctx, user) + assert.NoError(t, err) + + // Manually query database to verify + var count int + err = db.QueryRow("SELECT COUNT(*) FROM users WHERE email = $1", user.Email).Scan(&count) + assert.NoError(t, err) + assert.Equal(t, 1, count) +} +``` + +## 🎯 Best Practices + +### DO ✅ + +- ✅ Use real databases for repository tests +- ✅ Clean up test data after each test +- ✅ Test both success and error cases +- ✅ Use descriptive test names +- ✅ Test one thing per test function +- ✅ Use table-driven tests for multiple scenarios +- ✅ Run tests before committing +- ✅ Maintain high test coverage +- ✅ Test edge cases and boundary conditions + +### DON'T ❌ + +- ❌ Share test data between tests +- ❌ Rely on test execution order +- ❌ Skip cleanup in defer statements +- ❌ Use production databases for testing +- ❌ Hardcode database credentials +- ❌ Leave test databases running +- ❌ Commit commented-out tests +- ❌ Test implementation details instead of behavior + +## 📚 Testing Resources + +- [Go Testing Documentation](https://golang.org/pkg/testing/) +- [testify/assert](https://github.com/stretchr/testify) - Assertion library +- [Testing Best Practices](https://golang.org/doc/effective_go#testing) +- [Table Driven Tests](https://github.com/golang/go/wiki/TableDrivenTests) diff --git a/internal/outbox/domain/outbox_event.go b/internal/outbox/domain/outbox_event.go index b07d31e..5cb4cd2 100644 --- a/internal/outbox/domain/outbox_event.go +++ b/internal/outbox/domain/outbox_event.go @@ -18,13 +18,13 @@ const ( // OutboxEvent represents an event in the transactional outbox pattern type OutboxEvent struct { - ID uuid.UUID `db:"id" json:"id"` - EventType string `db:"event_type" json:"event_type" fieldtag:"insert,update"` - Payload string `db:"payload" json:"payload" fieldtag:"insert,update"` - Status OutboxEventStatus `db:"status" json:"status" fieldtag:"insert,update"` - Retries int `db:"retries" json:"retries" fieldtag:"insert,update"` - LastError *string `db:"last_error" json:"last_error,omitempty" fieldtag:"insert,update"` - ProcessedAt *time.Time `db:"processed_at" json:"processed_at,omitempty" fieldtag:"insert,update"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + ID uuid.UUID + EventType string + Payload string + Status OutboxEventStatus + Retries int + LastError *string + ProcessedAt *time.Time + CreatedAt time.Time + UpdatedAt time.Time } diff --git a/internal/user/domain/user.go b/internal/user/domain/user.go index 8ff5eae..ce32dde 100644 --- a/internal/user/domain/user.go +++ b/internal/user/domain/user.go @@ -11,12 +11,12 @@ import ( // User represents a user in the system type User struct { - ID uuid.UUID `db:"id"` - Name string `db:"name" fieldtag:"insert,update"` - Email string `db:"email" fieldtag:"insert,update"` - Password string `db:"password" fieldtag:"insert,update"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` + ID uuid.UUID + Name string + Email string + Password string + CreatedAt time.Time + UpdatedAt time.Time } // Domain-specific errors for user operations.