diff --git a/README.md b/README.md index 8784c26..2c05352 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ A production-ready Go project template following Clean Architecture and Domain-D - **Modular Domain Architecture** - Domain-based code organization for scalability - **Clean Architecture** - Separation of concerns with domain, repository, use case, and presentation layers +- **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 via unified repository layer - **Database Migrations** - Separate migrations for PostgreSQL and MySQL using golang-migrate @@ -39,19 +40,21 @@ go-project-template/ │ ├── 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 +│ │ └── response.go # JSON responses and error mapping │ ├── outbox/ # Outbox domain module │ │ ├── domain/ # Outbox entities │ │ │ └── outbox_event.go │ │ └── repository/ # Outbox data access │ │ └── outbox_repository.go │ ├── user/ # User domain module -│ │ ├── domain/ # User entities +│ │ ├── domain/ # User entities and domain errors │ │ │ └── user.go │ │ ├── http/ # User HTTP handlers │ │ │ ├── dto/ # Request/response DTOs @@ -81,16 +84,17 @@ go-project-template/ The project follows a modular domain architecture where each business domain is organized in its own directory with clear separation of concerns: -- **`domain/`** - Contains entities, value objects, and domain types (pure internal representation) +- **`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 +- **`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) ### Shared Utilities - **`app/`** - Dependency injection container for assembling application components -- **`httputil/`** - Shared HTTP utility functions used across all domain modules (e.g., `MakeJSONResponse`) +- **`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 - **`worker/`** - Background processing infrastructure @@ -276,6 +280,119 @@ The binary supports three commands via urfave/cli: ./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") +) +``` + +### Error Flow Through Layers + +**1. Repository Layer** - Transforms infrastructure errors to domain errors: + +```go +func (r *UserRepository) GetByID(ctx context.Context, id int64) (*domain.User, error) { + if err := sqlutil.Get(ctx, querier, "users", opts, &user); 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 +} +``` + +**2. Use Case Layer** - Returns domain errors directly: + +```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: + +```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) +} +``` + +### 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. + ## Development ### Build the application @@ -393,8 +510,9 @@ The project follows a modular domain-driven structure where each business domain - `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) +- `httputil/` - Reusable HTTP utilities (JSON responses, error handling, status code mapping) - `worker/` - Background event processing ### Benefits of This Structure @@ -414,17 +532,45 @@ To add a new domain (e.g., `product`): ``` internal/product/ ├── domain/ -│ └── product.go # Domain entity (no JSON tags) +│ └── product.go # Domain entity + domain errors ├── usecase/ │ └── product_usecase.go # UseCase interface + business logic ├── repository/ -│ └── product_repository.go # Data access +│ └── product_repository.go # Data access (returns domain errors) └── http/ ├── dto/ │ ├── request.go # API request DTOs │ ├── response.go # API response DTOs │ └── mapper.go # DTO-to-domain mappers - └── product_handler.go # HTTP handlers + └── 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" +) + +type Product struct { + ID int64 + 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 @@ -501,7 +647,11 @@ mux.HandleFunc("/api/products", productHandler.HandleProducts) **Tips:** - Define a UseCase interface in your usecase package to enable dependency inversion -- Use the shared `httputil.MakeJSONResponse` function in your HTTP handlers for consistent JSON responses +- 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 @@ -510,14 +660,16 @@ mux.HandleFunc("/api/products", productHandler.HandleProducts) ### Clean Architecture Layers -1. **Domain Layer** - Contains business entities and rules (e.g., `internal/user/domain`) -2. **Repository Layer** - Data access implementations using sqlutil (e.g., `internal/user/repository`) -3. **Use Case Layer** - UseCase interfaces and application business logic (e.g., `internal/user/usecase`) -4. **Presentation Layer** - HTTP handlers and server (e.g., `internal/user/http`) -5. **Utility Layer** - Shared utilities and helpers (e.g., `internal/httputil`) +1. **Domain Layer** - Contains business entities, domain errors, and rules (e.g., `internal/user/domain`) +2. **Repository Layer** - Data access implementations using sqlutil; 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: @@ -609,9 +761,7 @@ 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.MakeJSONResponse(w, http.StatusNotFound, map[string]string{ - "error": "product not found", - }) + httputil.HandleError(w, err, h.logger) return } @@ -619,7 +769,31 @@ func (h *ProductHandler) GetProduct(w http.ResponseWriter, r *http.Request) { } ``` -This ensures consistent response formatting across all HTTP endpoints and eliminates code duplication. +**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 diff --git a/internal/errors/errors.go b/internal/errors/errors.go new file mode 100644 index 0000000..c7a6118 --- /dev/null +++ b/internal/errors/errors.go @@ -0,0 +1,54 @@ +// Package errors provides standardized domain errors that express business intent +// rather than infrastructure details. These errors should be used by use cases +// and mapped to appropriate HTTP status codes by handlers. +package errors + +import ( + "errors" + "fmt" +) + +// Standard domain errors that can be used across all domain modules. +var ( + // ErrNotFound indicates the requested resource does not exist. + ErrNotFound = errors.New("not found") + + // ErrConflict indicates a conflict with existing data (e.g., duplicate key). + ErrConflict = errors.New("conflict") + + // ErrInvalidInput indicates the input data is invalid or fails validation. + ErrInvalidInput = errors.New("invalid input") + + // ErrUnauthorized indicates the request lacks valid authentication credentials. + ErrUnauthorized = errors.New("unauthorized") + + // ErrForbidden indicates the authenticated user doesn't have permission. + ErrForbidden = errors.New("forbidden") +) + +// New creates a new error with the given message. +// This is a convenience wrapper around errors.New for consistency. +func New(message string) error { + return errors.New(message) +} + +// Wrap wraps an error with additional context while preserving the error chain. +// Use this to add context at each layer without losing the original error type. +func Wrap(err error, message string) error { + if err == nil { + return nil + } + return fmt.Errorf("%s: %w", message, err) +} + +// Is reports whether any error in err's tree matches target. +// This is a convenience wrapper around errors.Is. +func Is(err, target error) bool { + return errors.Is(err, target) +} + +// As finds the first error in err's tree that matches target. +// This is a convenience wrapper around errors.As. +func As(err error, target any) bool { + return errors.As(err, target) +} diff --git a/internal/http/http_test.go b/internal/http/http_test.go index 5402220..b6a8881 100644 --- a/internal/http/http_test.go +++ b/internal/http/http_test.go @@ -288,7 +288,7 @@ func TestUserHandler_Register_InvalidJSON(t *testing.T) { var response map[string]string err := json.Unmarshal(w.Body.Bytes(), &response) require.NoError(t, err) - assert.Contains(t, response["error"], "invalid request body") + assert.Equal(t, "validation_error", response["error"]) } func TestUserHandler_Register_ValidationError(t *testing.T) { @@ -334,12 +334,13 @@ func TestUserHandler_Register_ValidationError(t *testing.T) { handler.RegisterUser(w, req) - assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Equal(t, http.StatusUnprocessableEntity, w.Code) var response map[string]string err := json.Unmarshal(w.Body.Bytes(), &response) require.NoError(t, err) - assert.Contains(t, response["error"], "required") + assert.Equal(t, "invalid_input", response["error"]) + assert.Contains(t, response["message"], "required") }) } } @@ -375,7 +376,7 @@ func TestUserHandler_Register_UseCaseError(t *testing.T) { var response map[string]string err := json.Unmarshal(w.Body.Bytes(), &response) require.NoError(t, err) - assert.Contains(t, response["error"], "failed to register user") + assert.Equal(t, "internal_error", response["error"]) mockUseCase.AssertExpectations(t) } diff --git a/internal/httputil/response.go b/internal/httputil/response.go index 2107011..4edb6fd 100644 --- a/internal/httputil/response.go +++ b/internal/httputil/response.go @@ -3,7 +3,10 @@ package httputil import ( "encoding/json" + "log/slog" "net/http" + + apperrors "github.com/allisson/go-project-template/internal/errors" ) // MakeJSONResponse writes a JSON response with the given status code and data @@ -14,3 +17,92 @@ func MakeJSONResponse(w http.ResponseWriter, statusCode int, data interface{}) { http.Error(w, "failed to encode response", http.StatusInternalServerError) } } + +// ErrorResponse represents a structured error response +type ErrorResponse struct { + Error string `json:"error"` + Message string `json:"message,omitempty"` + Code string `json:"code,omitempty"` +} + +// HandleError maps domain errors to HTTP status codes and writes an appropriate response. +// It logs the error with structured logging and returns a user-friendly error message. +func HandleError(w http.ResponseWriter, err error, logger *slog.Logger) { + if err == nil { + return + } + + var statusCode int + var errorResponse ErrorResponse + + // Map domain errors to HTTP status codes + switch { + case apperrors.Is(err, apperrors.ErrNotFound): + statusCode = http.StatusNotFound + errorResponse = ErrorResponse{ + Error: "not_found", + Message: "The requested resource was not found", + } + + case apperrors.Is(err, apperrors.ErrConflict): + statusCode = http.StatusConflict + errorResponse = ErrorResponse{ + Error: "conflict", + Message: "A conflict occurred with existing data", + } + + case apperrors.Is(err, apperrors.ErrInvalidInput): + statusCode = http.StatusUnprocessableEntity + errorResponse = ErrorResponse{ + Error: "invalid_input", + Message: err.Error(), + } + + case apperrors.Is(err, apperrors.ErrUnauthorized): + statusCode = http.StatusUnauthorized + errorResponse = ErrorResponse{ + Error: "unauthorized", + Message: "Authentication is required", + } + + case apperrors.Is(err, apperrors.ErrForbidden): + statusCode = http.StatusForbidden + errorResponse = ErrorResponse{ + Error: "forbidden", + Message: "You don't have permission to access this resource", + } + + default: + // For unknown/internal errors, don't expose details to the client + statusCode = http.StatusInternalServerError + errorResponse = ErrorResponse{ + Error: "internal_error", + Message: "An internal error occurred", + } + } + + // Log the full error details (including wrapped errors) + if logger != nil { + logger.Error("request failed", + slog.Int("status_code", statusCode), + slog.String("error_code", errorResponse.Error), + slog.Any("error", err), + ) + } + + MakeJSONResponse(w, statusCode, errorResponse) +} + +// HandleValidationError writes a 400 Bad Request response for validation errors +func HandleValidationError(w http.ResponseWriter, err error, logger *slog.Logger) { + if logger != nil { + logger.Warn("validation failed", slog.Any("error", err)) + } + + errorResponse := ErrorResponse{ + Error: "validation_error", + Message: err.Error(), + } + + MakeJSONResponse(w, http.StatusBadRequest, errorResponse) +} diff --git a/internal/user/domain/user.go b/internal/user/domain/user.go index 650bba1..3fa7a30 100644 --- a/internal/user/domain/user.go +++ b/internal/user/domain/user.go @@ -1,7 +1,11 @@ // Package domain defines the core user domain entities and types. package domain -import "time" +import ( + "time" + + "github.com/allisson/go-project-template/internal/errors" +) // User represents a user in the system type User struct { @@ -12,3 +16,27 @@ type User struct { CreatedAt time.Time `db:"created_at"` UpdatedAt time.Time `db:"updated_at"` } + +// Domain-specific errors for user operations. +var ( + // ErrUserNotFound indicates the requested user does not exist. + ErrUserNotFound = errors.Wrap(errors.ErrNotFound, "user not found") + + // ErrUserAlreadyExists indicates a user with the same email already exists. + ErrUserAlreadyExists = errors.Wrap(errors.ErrConflict, "user already exists") + + // ErrInvalidEmail indicates the email format is invalid. + ErrInvalidEmail = errors.Wrap(errors.ErrInvalidInput, "invalid email format") + + // ErrInvalidPassword indicates the password doesn't meet requirements. + ErrInvalidPassword = errors.Wrap(errors.ErrInvalidInput, "invalid password") + + // ErrNameRequired indicates the name field is required. + ErrNameRequired = errors.Wrap(errors.ErrInvalidInput, "name is required") + + // ErrEmailRequired indicates the email field is required. + ErrEmailRequired = errors.Wrap(errors.ErrInvalidInput, "email is required") + + // ErrPasswordRequired indicates the password field is required. + ErrPasswordRequired = errors.Wrap(errors.ErrInvalidInput, "password is required") +) diff --git a/internal/user/http/dto/request.go b/internal/user/http/dto/request.go index 73df239..5024157 100644 --- a/internal/user/http/dto/request.go +++ b/internal/user/http/dto/request.go @@ -1,7 +1,7 @@ // Package dto provides data transfer objects for the user HTTP layer. package dto -import "errors" +import "github.com/allisson/go-project-template/internal/user/domain" // RegisterUserRequest represents the API request for user registration type RegisterUserRequest struct { @@ -11,15 +11,17 @@ type RegisterUserRequest struct { } // Validate validates the RegisterUserRequest +// Note: This provides basic JSON structure validation. +// Detailed validation is handled by the use case layer. func (r *RegisterUserRequest) Validate() error { if r.Name == "" { - return errors.New("name is required") + return domain.ErrNameRequired } if r.Email == "" { - return errors.New("email is required") + return domain.ErrEmailRequired } if r.Password == "" { - return errors.New("password is required") + return domain.ErrPasswordRequired } return nil } diff --git a/internal/user/http/user_handler.go b/internal/user/http/user_handler.go index b691db0..d7aa7ed 100644 --- a/internal/user/http/user_handler.go +++ b/internal/user/http/user_handler.go @@ -34,16 +34,13 @@ func (h *UserHandler) RegisterUser(w http.ResponseWriter, r *http.Request) { var req dto.RegisterUserRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - if h.logger != nil { - h.logger.Error("failed to decode request body", slog.Any("error", err)) - } - httputil.MakeJSONResponse(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) + httputil.HandleValidationError(w, err, h.logger) return } - // Validate request + // Validate request structure if err := req.Validate(); err != nil { - httputil.MakeJSONResponse(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + httputil.HandleError(w, err, h.logger) return } @@ -52,10 +49,7 @@ func (h *UserHandler) RegisterUser(w http.ResponseWriter, r *http.Request) { user, err := h.userUseCase.RegisterUser(r.Context(), input) if err != nil { - if h.logger != nil { - h.logger.Error("failed to register user", slog.Any("error", err)) - } - httputil.MakeJSONResponse(w, http.StatusInternalServerError, map[string]string{"error": "failed to register user"}) + httputil.HandleError(w, err, h.logger) return } diff --git a/internal/user/repository/user_repository.go b/internal/user/repository/user_repository.go index d8cbe6e..aee5f75 100644 --- a/internal/user/repository/user_repository.go +++ b/internal/user/repository/user_repository.go @@ -4,10 +4,14 @@ package repository import ( "context" "database/sql" + "errors" + "strings" "github.com/allisson/go-project-template/internal/database" "github.com/allisson/go-project-template/internal/user/domain" "github.com/allisson/sqlutil" + + apperrors "github.com/allisson/go-project-template/internal/errors" ) // UserRepository handles user persistence @@ -31,7 +35,14 @@ func NewUserRepository(db *sql.DB, driver string) *UserRepository { // Create inserts a new user func (r *UserRepository) Create(ctx context.Context, user *domain.User) error { querier := database.GetTx(ctx, r.db) - return sqlutil.Insert(ctx, querier, r.flavor, "insert", "users", user) + if err := sqlutil.Insert(ctx, querier, r.flavor, "insert", "users", user); err != nil { + // Check for unique constraint violation (duplicate email) + if isUniqueViolation(err) { + return domain.ErrUserAlreadyExists + } + return apperrors.Wrap(err, "failed to create user") + } + return nil } // GetByID retrieves a user by ID @@ -40,7 +51,10 @@ func (r *UserRepository) GetByID(ctx context.Context, id int64) (*domain.User, e opts := sqlutil.NewFindOptions(r.flavor).WithFilter("id", id) querier := database.GetTx(ctx, r.db) if err := sqlutil.Get(ctx, querier, "users", opts, &user); err != nil { - return nil, err + if errors.Is(err, sql.ErrNoRows) { + return nil, domain.ErrUserNotFound + } + return nil, apperrors.Wrap(err, "failed to get user by id") } return &user, nil } @@ -51,7 +65,25 @@ func (r *UserRepository) GetByEmail(ctx context.Context, email string) (*domain. opts := sqlutil.NewFindOptions(r.flavor).WithFilter("email", email) querier := database.GetTx(ctx, r.db) if err := sqlutil.Get(ctx, querier, "users", opts, &user); err != nil { - return nil, err + if errors.Is(err, sql.ErrNoRows) { + return nil, domain.ErrUserNotFound + } + return nil, apperrors.Wrap(err, "failed to get user by email") } return &user, nil } + +// isUniqueViolation checks if the error is a unique constraint violation. +// This works for both PostgreSQL and MySQL drivers. +func isUniqueViolation(err error) bool { + if err == nil { + return false + } + errMsg := strings.ToLower(err.Error()) + // PostgreSQL: "duplicate key value violates unique constraint" or "pq: duplicate key" + // MySQL: "Error 1062: Duplicate entry" + return strings.Contains(errMsg, "duplicate key") || + strings.Contains(errMsg, "unique constraint") || + strings.Contains(errMsg, "duplicate entry") || + strings.Contains(errMsg, "1062") +} diff --git a/internal/user/repository/user_repository_test.go b/internal/user/repository/user_repository_test.go index 271e057..562353b 100644 --- a/internal/user/repository/user_repository_test.go +++ b/internal/user/repository/user_repository_test.go @@ -7,6 +7,7 @@ import ( "time" "github.com/DATA-DOG/go-sqlmock" + apperrors "github.com/allisson/go-project-template/internal/errors" "github.com/allisson/go-project-template/internal/user/domain" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -111,7 +112,7 @@ func TestUserRepository_GetByID_NotFound(t *testing.T) { user, err := repo.GetByID(ctx, 999) assert.Error(t, err) assert.Nil(t, user) - assert.Contains(t, err.Error(), "sql: no rows in result set") + assert.True(t, apperrors.Is(err, domain.ErrUserNotFound)) assert.NoError(t, mock.ExpectationsWereMet()) } @@ -162,6 +163,6 @@ func TestUserRepository_GetByEmail_NotFound(t *testing.T) { user, err := repo.GetByEmail(ctx, "notfound@example.com") assert.Error(t, err) assert.Nil(t, user) - assert.Contains(t, err.Error(), "sql: no rows in result set") + assert.True(t, apperrors.Is(err, domain.ErrUserNotFound)) assert.NoError(t, mock.ExpectationsWereMet()) } diff --git a/internal/user/usecase/user_usecase.go b/internal/user/usecase/user_usecase.go index 79a6429..4612e70 100644 --- a/internal/user/usecase/user_usecase.go +++ b/internal/user/usecase/user_usecase.go @@ -4,9 +4,10 @@ package usecase import ( "context" "encoding/json" - "fmt" + "strings" "github.com/allisson/go-project-template/internal/database" + apperrors "github.com/allisson/go-project-template/internal/errors" outboxDomain "github.com/allisson/go-project-template/internal/outbox/domain" "github.com/allisson/go-project-template/internal/user/domain" "github.com/allisson/go-pwdhash" @@ -57,7 +58,7 @@ func NewUserUseCase( // Initialize password hasher with interactive policy for user passwords hasher, err := pwdhash.New(pwdhash.WithPolicy(pwdhash.PolicyInteractive)) if err != nil { - return nil, fmt.Errorf("failed to create password hasher: %w", err) + return nil, apperrors.Wrap(err, "failed to create password hasher") } return &UserUseCase{ @@ -68,25 +69,48 @@ func NewUserUseCase( }, nil } +// validateRegisterUserInput validates the registration input +func (uc *UserUseCase) validateRegisterUserInput(input RegisterUserInput) error { + if strings.TrimSpace(input.Name) == "" { + return domain.ErrNameRequired + } + if strings.TrimSpace(input.Email) == "" { + return domain.ErrEmailRequired + } + if input.Password == "" { + return domain.ErrPasswordRequired + } + // Basic email validation + if !strings.Contains(input.Email, "@") || !strings.Contains(input.Email, ".") { + return domain.ErrInvalidEmail + } + return nil +} + // RegisterUser registers a new user and creates a user.created event func (uc *UserUseCase) RegisterUser(ctx context.Context, input RegisterUserInput) (*domain.User, error) { + // Validate input + if err := uc.validateRegisterUserInput(input); err != nil { + return nil, err + } + // Hash the password hashedPassword, err := uc.passwordHasher.Hash([]byte(input.Password)) if err != nil { - return nil, fmt.Errorf("failed to hash password: %w", err) + return nil, apperrors.Wrap(err, "failed to hash password") } user := &domain.User{ - Name: input.Name, - Email: input.Email, + Name: strings.TrimSpace(input.Name), + Email: strings.TrimSpace(strings.ToLower(input.Email)), Password: hashedPassword, } // Execute within a transaction err = uc.txManager.WithTx(ctx, func(ctx context.Context) error { - // Create user + // Create user - repository will return domain errors if err := uc.userRepo.Create(ctx, user); err != nil { - return fmt.Errorf("failed to create user: %w", err) + return err } // Create user.created event payload @@ -97,7 +121,7 @@ func (uc *UserUseCase) RegisterUser(ctx context.Context, input RegisterUserInput } payloadJSON, err := json.Marshal(eventPayload) if err != nil { - return fmt.Errorf("failed to marshal event payload: %w", err) + return apperrors.Wrap(err, "failed to marshal event payload") } // Create outbox event @@ -109,7 +133,7 @@ func (uc *UserUseCase) RegisterUser(ctx context.Context, input RegisterUserInput } if err := uc.outboxRepo.Create(ctx, outboxEvent); err != nil { - return fmt.Errorf("failed to create outbox event: %w", err) + return apperrors.Wrap(err, "failed to create outbox event") } return nil diff --git a/internal/user/usecase/user_usecase_test.go b/internal/user/usecase/user_usecase_test.go index 6f316f7..6ddf6a8 100644 --- a/internal/user/usecase/user_usecase_test.go +++ b/internal/user/usecase/user_usecase_test.go @@ -149,7 +149,8 @@ func TestUserUseCase_RegisterUser_CreateUserError(t *testing.T) { assert.Error(t, err) assert.Nil(t, user) - assert.Contains(t, err.Error(), "failed to create user") + // The error should be the database error returned by the repository + assert.Equal(t, createError, err) txManager.AssertExpectations(t) userRepo.AssertExpectations(t)