diff --git a/.editorconfig b/.editorconfig index b3d4529..4a80a8d 100644 --- a/.editorconfig +++ b/.editorconfig @@ -3,14 +3,14 @@ root = true [*] charset = utf-8 -indent_size = 4 end_of_line = lf indent_style = space -trim_trailing_whitespace = true insert_final_newline = true - -[Makefile] -indent_style = tab +trim_trailing_whitespace = true [*.go] -indent_style = tab +indent_style = space +indent_size = 4 + +[*.{json,yml,yaml}] +indent_size = 2 diff --git a/.github/workflows/github-actions-demo.yaml b/.github/workflows/github-actions-demo.yaml index 6caafe9..0b91063 100644 --- a/.github/workflows/github-actions-demo.yaml +++ b/.github/workflows/github-actions-demo.yaml @@ -2,11 +2,9 @@ name: CI/CD with Docker for Golang on: push: - branches: - - master + branches: [ main, master ] pull_request: - branches: - - master + branches: [ main, master ] jobs: build-and-test: @@ -43,7 +41,8 @@ jobs: go-version: 1.23 - name: Install dependencies run: go mod tidy - + - name: Build + run: go build -v -o hexagonal-app ./cmd/main.go - name: Run Tests run: go test -v ./... diff --git a/.golangci.yml b/.golangci.yml index c9c069d..2b9bc0d 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -5,14 +5,12 @@ linters: - wrapcheck - testpackage - tagliatelle - - nosnakecase - nlreturn - funlen - gofumpt - gochecknoglobals - gocognit - godox - - gomnd - lll - wsl - forbidigo @@ -21,8 +19,36 @@ linters: - gci - dogsled - gochecknoinits - - scopelint - - tagalign - depguard - cyclop - nosprintfhostport + - mnd + - tagalign + +linters-settings: + goimports: + local-prefixes: go-hexagonal + revive: + rules: + - name: var-naming + - name: exported + arguments: + - "disableStutteringCheck" + - name: package-comments + - name: dot-imports + - name: blank-imports + - name: context-keys-type + - name: context-as-argument + - name: error-return + - name: error-strings + - name: error-naming + - name: increment-decrement + - name: var-declaration + - name: range + - name: receiver-naming + - name: time-naming + - name: indent-error-flow + - name: empty-block + - name: superfluous-else + - name: modifies-parameter + - name: unreachable-code diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 21d8a1c..da83bf9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -12,9 +12,11 @@ repos: - repo: https://github.com/dnephin/pre-commit-golang rev: v0.5.1 hooks: - - id: no-go-testing - - id: golangci-lint + - id: go-fmt + - id: go-imports - id: go-unit-tests + - id: go-build + - id: go-mod-tidy - repo: https://github.com/detailyang/pre-commit-shell rev: 1.0.5 hooks: @@ -25,3 +27,7 @@ repos: - id: commitlint stages: [commit-msg] additional_dependencies: ['@commitlint/config-conventional'] + - repo: https://github.com/golangci/golangci-lint + rev: v1.64.8 + hooks: + - id: golangci-lint diff --git a/CODING_STYLE.md b/CODING_STYLE.md new file mode 100644 index 0000000..c7ae92b --- /dev/null +++ b/CODING_STYLE.md @@ -0,0 +1,255 @@ +# Go Hexagonal Project Coding Standards + +## Directory Structure + +The project follows the Hexagonal Architecture structure design: + +``` +go-hexagonal/ +├── adapter/ # Adapter Layer - Connecting domain with external infrastructure +│ ├── repository/ # Repository implementations +│ ├── dependency/ # Dependency injection +│ ├── job/ # Background tasks +│ └── amqp/ # Message queue +├── api/ # API Layer - Handling HTTP, gRPC requests +│ ├── http/ # HTTP API handlers +│ ├── grpc/ # gRPC API handlers +│ ├── error_code/ # Error code definitions +│ └── dto/ # Data Transfer Objects +├── application/ # Application Layer - Orchestrating business flows +│ ├── example/ # Example application services +│ └── core/ # Core interfaces and utilities +├── domain/ # Domain Layer - Core business logic +│ ├── service/ # Domain services +│ ├── repo/ # Repository interfaces +│ ├── event/ # Domain events +│ ├── vo/ # Value objects +│ ├── model/ # Domain models +│ └── aggregate/ # Aggregate roots +├── cmd/ # Application entry points +├── config/ # Configuration +├── tests/ # Tests +└── util/ # Utilities +``` + +## Naming Conventions + +### Package Naming Conventions + +- Use lowercase words, no underscores or mixed case +- Package names should be short, meaningful nouns +- Avoid using common variable names as package names + +```go +// Correct +package repository +package service + +// Incorrect +package Repository +package service_impl +``` + +### Variable Naming Conventions + +- Local variables: Use camelCase, e.g., `userID` instead of `userid` +- Global variables: Use camelCase, capitalize first letter if exported +- Constants: Use all uppercase with underscores, e.g., `MAX_CONNECTIONS` + +```go +// Local variables +func processUser() { + userID := 123 + firstName := "John" +} + +// Global variables +var ( + GlobalConfig Configuration + maxRetryCount = 3 +) + +// Constants +const ( + MAX_CONNECTIONS = 100 + DEFAULT_TIMEOUT = 30 +) +``` + +### Interface and Struct Naming Conventions + +- Interface naming: Usually end with "er", e.g., `Reader`, `Writer` +- Structs implementing specific interfaces: Should be named after functionality rather than interface name +- Avoid abbreviations unless they are very common (like HTTP, URL) + +```go +// Interface +type EventHandler interface { + Handle(event Event) error +} + +// Implementation +type LoggingEventHandler struct { + logger Logger +} +``` + +## Code Format and Style + +All code must pass `go fmt` and `golangci-lint` checks to ensure consistent style: + +```bash +# Use make commands for checks +make fmt +make lint +``` + +### Import Package Ordering + +Arrange imports in the following order: + +1. Standard library +2. Third-party packages +3. Internal project packages + +```go +import ( + // Standard library + "context" + "fmt" + + // Third-party packages + "github.com/gin-gonic/gin" + "go.uber.org/zap" + + // Internal project packages + "go-hexagonal/domain/model" + "go-hexagonal/util/log" +) +``` + +### File Internal Structure + +File content should be organized in the following order: + +1. Package documentation comments +2. Package declaration +3. Import packages +4. Constants +5. Variables +6. Type definitions +7. Function definitions + +## Comment Standards + +### Package Comments + +Each package should have package comments placed before the package statement: + +```go +// Package repository provides data access implementations +// for the domain repositories. +package repository +``` + +### Exported Functions and Types Comments + +All exported functions, types, constants, and variables should have comments: + +```go +// ExampleService handles business logic for Example entities. +// It provides CRUD operations and domain-specific validations. +type ExampleService struct { + // fields +} + +// Create creates a new example entity with the given data. +// It validates the input and publishes an event on successful creation. +// Returns the created entity or an error if validation fails. +func (s *ExampleService) Create(ctx context.Context, example *model.Example) (*model.Example, error) { + // implementation +} +``` + +## Error Handling Standards + +### Using Unified Error Handling Library + +The project uses the `util/errors` package for unified error handling: + +```go +import "go-hexagonal/util/errors" + +// Creating errors +if input.Name == "" { + return nil, errors.NewValidationError("Name cannot be empty", nil) +} + +// Wrapping errors +result, err := repository.Find(id) +if err != nil { + return nil, errors.Wrap(err, errors.ErrorTypePersistence, "Failed to query record") +} + +// Error type checking +if errors.IsNotFoundError(err) { + // Handle resource not found case +} +``` + +### HTTP Layer Error Handling + +The API layer uses a unified error handling middleware: + +```go +// Middleware is configured in router.go +router.Use(middleware.ErrorHandlerMiddleware()) +``` + +## Testing Standards + +### Test Naming Conventions + +- Test functions should be named `TestXxx`, where `Xxx` is the name of the function being tested +- Table-driven test variables should be named `tests` or `testCases` + +```go +func TestExampleService_Create(t *testing.T) { + tests := []struct { + name string + input *model.Example + mockSetup func(repo *mocks.MockExampleRepo) + wantErr bool + expectedErr string + }{ + // Test cases + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Test implementation + }) + } +} +``` + +## CI/CD Standards + +The project uses GitHub Actions for continuous integration to ensure code quality: + +- Every commit and PR will run code checks and tests +- Code must pass all checks and tests to be merged +- It's recommended to use pre-commit hooks to check code quality locally + +```bash +# Install pre-commit hooks +make pre-commit.install +``` + +## Best Practices + +1. **Dependency Injection**: Always use dependency injection, avoid global variables and singletons +2. **Context Passing**: Always pass context through function calls for cancellation and timeout control +3. **Error Handling**: Use unified error handling, don't discard errors, wrap errors appropriately +4. **Test Coverage**: Ensure critical code has sufficient test coverage, use table-driven tests +5. **Concurrency Safety**: Ensure data structures accessed concurrently are thread-safe diff --git a/Makefile b/Makefile index d8cea62..2f3d8e7 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,5 @@ +.PHONY: fmt lint test + init: @echo "=== 👩‍🌾 Init Go Project with Pre-commit Hooks ===" brew install go @@ -12,11 +14,15 @@ init: pre-commit install @echo "=== ✅ Done. ===" +fmt: + go fmt ./... + goimports -w -local "go-hexagonal" ./ + test: @echo "=== 🦸‍️ Prepare Dependency ===" go mod tidy @echo "=== 🦸‍️ Start Unit Test ===" - go test ./... -v + go test -v -race -cover ./... pre-commit.install: @echo "=== 🙆 Setup Pre-commit ===" @@ -31,3 +37,5 @@ precommit.rehook: ci.lint: @echo "=== 🙆 Start CI Linter ===" golangci-lint run -v ./... --fix + +all: fmt ci.lint test diff --git a/README.md b/README.md index 3106bd5..38a4bbf 100644 --- a/README.md +++ b/README.md @@ -251,6 +251,33 @@ func (h *CreateExampleHandler) Handle(ctx context.Context, input interface{}) (i } ``` +## Coding Standards + +This project follows unified coding standards to ensure code quality and consistency. For detailed guidelines, please refer to [CODING_STYLE.md](./CODING_STYLE.md). + +Key standards include: + +- Code format and style (using go fmt and golangci-lint) +- Naming conventions (package names, variable names, interfaces and structs) +- Import package ordering +- Comment standards +- Error handling standards (using util/errors package) +- Testing standards +- CI/CD standards + +Developers should ensure compliance with these standards before submitting code. Use the following commands for verification: + +```bash +# Format code +make fmt + +# Code quality check +make lint + +# Run tests +make test +``` + ## Transaction Management This project implements transaction interfaces and no-operation transactions, supporting different transaction management strategies: diff --git a/README.zh.md b/README.zh.md index 87733ee..6794239 100644 --- a/README.zh.md +++ b/README.zh.md @@ -84,7 +84,7 @@ - **模型(Models)**: 领域实体和值对象 - `Example`: 示例实体,包含基本属性如ID、名称、别名等 - + - **资源库接口(Repository Interfaces)**: 定义数据访问接口 - `IExampleRepo`: 示例资源库接口,定义了创建、读取、更新、删除等操作 - `IExampleCacheRepo`: 示例缓存接口,定义了健康检查方法 @@ -273,7 +273,7 @@ type NoopTransaction struct { func (s *ExampleService) Create(ctx context.Context, example *model.Example) (*model.Example, error) { // 创建一个无操作事务 tr := repo.NewNoopTransaction(s.Repository) - + createdExample, err := s.Repository.Create(ctx, tr, example) // ... } diff --git a/adapter/dependency/wire_gen.go b/adapter/dependency/wire_gen.go index f4b2a2a..2b90363 100644 --- a/adapter/dependency/wire_gen.go +++ b/adapter/dependency/wire_gen.go @@ -8,6 +8,7 @@ package dependency import ( "context" + "go-hexagonal/adapter/repository/mysql/entity" "go-hexagonal/domain/event" "go-hexagonal/domain/repo" diff --git a/adapter/job/job.go b/adapter/job/job.go index 9c3cacc..6aef835 100644 --- a/adapter/job/job.go +++ b/adapter/job/job.go @@ -28,6 +28,9 @@ type Scheduler struct { mu sync.RWMutex } +// DefaultJobTimeout is the default timeout for job execution +const DefaultJobTimeout = 5 * time.Minute + // NewScheduler creates a new job scheduler func NewScheduler() *Scheduler { return &Scheduler{ @@ -47,7 +50,7 @@ func (s *Scheduler) AddJob(spec string, job Job) error { } _, err := s.cron.AddFunc(spec, func() { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + ctx, cancel := context.WithTimeout(context.Background(), DefaultJobTimeout) defer cancel() start := time.Now() diff --git a/adapter/repository/mysql/repo.go b/adapter/repository/mysql/repo.go new file mode 100644 index 0000000..d89393b --- /dev/null +++ b/adapter/repository/mysql/repo.go @@ -0,0 +1,11 @@ +// Package mysql provides MySQL database implementation for repositories +package mysql + +import ( + "go-hexagonal/adapter/repository/mysql/entity" +) + +// NewExampleRepo creates a new instance of Example repository +func NewExampleRepo() *entity.EntityExample { + return entity.NewExample() +} diff --git a/api/error_code/common_code.go b/api/error_code/common_code.go index 27ac9e1..e379c98 100644 --- a/api/error_code/common_code.go +++ b/api/error_code/common_code.go @@ -35,7 +35,7 @@ var ( UnauthorizedAuthNotExist = NewError(UnauthorizedAuthNotExistErrorCode, "unauthorized, auth not exists") UnauthorizedTokenError = NewError(UnauthorizedTokenErrorCode, "unauthorized, token invalid") UnauthorizedTokenTimeout = NewError(UnauthorizedTokenTimeoutErrorCode, "unauthorized, token timeout") - UnauthorizedTokenGenerate = NewError(UnauthorizedTokenGenerateErrorCode, "unauthorized,token generate failed") + UnauthorizedTokenGenerate = NewError(UnauthorizedTokenGenerateErrorCode, "unauthorized, token generate failed") ) // Internal error code diff --git a/api/http/example.go b/api/http/example.go index 2a7ea6d..9eff56e 100644 --- a/api/http/example.go +++ b/api/http/example.go @@ -12,7 +12,6 @@ import ( "go-hexagonal/api/http/handle" "go-hexagonal/api/http/validator" "go-hexagonal/domain/model" - "go-hexagonal/domain/service" "go-hexagonal/util/log" ) @@ -36,7 +35,7 @@ func CreateExample(ctx *gin.Context) { return } - example, err = service.ExampleSvc.Create(ctx, example) + example, err = services.ExampleService.Create(ctx, example) if err != nil { log.SugaredLogger.Errorf("CreateExample failed: %v", err.Error()) response.ToErrorResponse(error_code.ServerError) @@ -58,7 +57,7 @@ func DeleteExample(ctx *gin.Context) { return } - err := service.ExampleSvc.Delete(ctx, param.Id) + err := services.ExampleService.Delete(ctx, param.Id) if err != nil { log.SugaredLogger.Errorf("DeleteExample failed.%v", err.Error()) response.ToErrorResponse(error_code.ServerError) @@ -80,7 +79,7 @@ func UpdateExample(ctx *gin.Context) { } example := &model.Example{} copier.Copy(example, body) - err := service.ExampleSvc.Update(ctx, example) + err := services.ExampleService.Update(ctx, example) if err != nil { log.SugaredLogger.Errorf("UpdateExample failed.%v", err.Error()) response.ToErrorResponse(error_code.ServerError) @@ -100,7 +99,7 @@ func GetExample(ctx *gin.Context) { response.ToErrorResponse(errResp) return } - result, err := service.ExampleSvc.Get(ctx, param.Id) + result, err := services.ExampleService.Get(ctx, param.Id) if err != nil { log.SugaredLogger.Errorf("GetExample failed.%v", err.Error()) response.ToErrorResponse(error_code.ServerError) @@ -119,7 +118,7 @@ func FindExampleByName(ctx *gin.Context) { return } - result, err := service.ExampleSvc.FindByName(ctx, name) + result, err := services.ExampleService.FindByName(ctx, name) if err != nil { log.SugaredLogger.Errorf("FindExampleByName failed.%v", err.Error()) if err.Error() == "record not found" || diff --git a/api/http/example_test.go b/api/http/example_test.go index c05f723..dfaee60 100644 --- a/api/http/example_test.go +++ b/api/http/example_test.go @@ -60,14 +60,19 @@ func (m *MockExampleRepo) FindByName(ctx context.Context, tr repo.Transaction, n // Setup test func setupTest(t *testing.T) (*gin.Engine, *MockExampleRepo, *service.ExampleService, func()) { // Save original service - originalService := service.ExampleSvc + originalServices := services // Create mock and new service mockRepo := new(MockExampleRepo) testService := service.NewExampleService(mockRepo) - // Replace global service - service.ExampleSvc = testService + // Create test services + testServices := &service.Services{ + ExampleService: testService, + } + + // Register test services + RegisterServices(testServices) // Set up Gin gin.SetMode(gin.TestMode) @@ -75,7 +80,7 @@ func setupTest(t *testing.T) (*gin.Engine, *MockExampleRepo, *service.ExampleSer // Return cleanup function cleanup := func() { - service.ExampleSvc = originalService + services = originalServices } return router, mockRepo, testService, cleanup @@ -103,7 +108,7 @@ func TestCreateExample(t *testing.T) { "alias": "test", } jsonData, _ := json.Marshal(requestBody) - req, _ := http.NewRequest("POST", "/api/examples", bytes.NewBuffer(jsonData)) + req, _ := http.NewRequest(http.MethodPost, "/api/examples", bytes.NewBuffer(jsonData)) req.Header.Set("Content-Type", "application/json") // Execute request @@ -132,7 +137,7 @@ func TestGetExample(t *testing.T) { mockRepo.On("GetByID", mock.Anything, mock.Anything, 1).Return(expectedExample, nil) // Create request - req, _ := http.NewRequest("GET", "/api/examples/1", nil) + req, _ := http.NewRequest(http.MethodGet, "/api/examples/1", nil) // Execute request recorder := httptest.NewRecorder() @@ -158,7 +163,7 @@ func TestUpdateExample(t *testing.T) { "alias": "updated", } jsonData, _ := json.Marshal(requestBody) - req, _ := http.NewRequest("PUT", "/api/examples/1", bytes.NewBuffer(jsonData)) + req, _ := http.NewRequest(http.MethodPut, "/api/examples/1", bytes.NewBuffer(jsonData)) req.Header.Set("Content-Type", "application/json") // Execute request @@ -180,7 +185,7 @@ func TestDeleteExample(t *testing.T) { mockRepo.On("Delete", mock.Anything, mock.Anything, 1).Return(nil) // Create request - req, _ := http.NewRequest("DELETE", "/api/examples/1", nil) + req, _ := http.NewRequest(http.MethodDelete, "/api/examples/1", nil) // Execute request recorder := httptest.NewRecorder() @@ -208,7 +213,7 @@ func TestFindExampleByName(t *testing.T) { mockRepo.On("FindByName", mock.Anything, mock.Anything, "test").Return(expectedExample, nil) // Create request - req, _ := http.NewRequest("GET", "/api/examples/name/test", nil) + req, _ := http.NewRequest(http.MethodGet, "/api/examples/name/test", nil) // Execute request recorder := httptest.NewRecorder() @@ -222,7 +227,7 @@ func TestFindExampleByName(t *testing.T) { Return(nil, fmt.Errorf("record not found")) // Create request - req, _ = http.NewRequest("GET", "/api/examples/name/nonexistent", nil) + req, _ = http.NewRequest(http.MethodGet, "/api/examples/name/nonexistent", nil) // Execute request recorder = httptest.NewRecorder() diff --git a/api/http/main_test.go b/api/http/main_test.go index fc33e50..2ecaa93 100644 --- a/api/http/main_test.go +++ b/api/http/main_test.go @@ -4,10 +4,10 @@ import ( "context" "testing" + "go-hexagonal/adapter/dependency" "go-hexagonal/adapter/repository" "go-hexagonal/adapter/repository/mysql/entity" "go-hexagonal/config" - "go-hexagonal/domain/service" "go-hexagonal/util/log" ) @@ -17,10 +17,18 @@ func TestMain(m *testing.M) { config.Init("../../config", "config") log.Init() + // Initialize repositories repository.Init(repository.WithMySQL(), repository.WithRedis()) _ = repository.Clients.MySQL.GetDB(ctx).AutoMigrate(&entity.EntityExample{}) - service.Init(ctx) + // Initialize services using dependency injection + svcs, err := dependency.InitializeServices(ctx) + if err != nil { + log.SugaredLogger.Fatalf("Failed to initialize services: %v", err) + } + + // Register services for API handlers + RegisterServices(svcs) m.Run() } diff --git a/api/http/middleware/cors.go b/api/http/middleware/cors.go index 5b53691..2d4a640 100644 --- a/api/http/middleware/cors.go +++ b/api/http/middleware/cors.go @@ -7,14 +7,20 @@ import ( "github.com/gin-gonic/gin" ) -// Cors returns a middleware that enables CORS +// CORS related constants +const ( + // CORSMaxAge defines the maximum age for CORS preflight requests + CORSMaxAge = 12 * time.Hour +) + +// Cors provides a CORS middleware func Cors() gin.HandlerFunc { return cors.New(cors.Config{ AllowOrigins: []string{"*"}, - AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}, - AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization", "X-Requested-With", "X-Request-ID"}, - ExposeHeaders: []string{"Content-Length", "Content-Type", "X-Request-ID"}, + AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, + AllowHeaders: []string{"Origin", "Authorization", "Content-Type"}, + ExposeHeaders: []string{"Content-Length"}, AllowCredentials: true, - MaxAge: 12 * time.Hour, + MaxAge: CORSMaxAge, }) } diff --git a/api/http/middleware/error_handler.go b/api/http/middleware/error_handler.go new file mode 100644 index 0000000..b982934 --- /dev/null +++ b/api/http/middleware/error_handler.go @@ -0,0 +1,54 @@ +// Package middleware provides HTTP request processing middleware +package middleware + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "go-hexagonal/api/error_code" + "go-hexagonal/util/errors" +) + +// ErrorHandlerMiddleware handles API layer error responses uniformly +func ErrorHandlerMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + c.Next() + + // Check if there are any errors + if len(c.Errors) > 0 { + err := c.Errors.Last().Err + + // Return appropriate HTTP status code and error message based on error type + switch { + case errors.IsValidationError(err): + c.JSON(http.StatusBadRequest, gin.H{ + "code": error_code.InvalidParamsCode, + "message": err.Error(), + }) + return + + case errors.IsNotFoundError(err): + c.JSON(http.StatusNotFound, gin.H{ + "code": error_code.NotFoundCode, + "message": err.Error(), + }) + return + + case errors.IsPersistenceError(err): + c.JSON(http.StatusInternalServerError, gin.H{ + "code": error_code.ServerErrorCode, + "message": "Database operation failed", + }) + return + + default: + // Default server error + c.JSON(http.StatusInternalServerError, gin.H{ + "code": error_code.ServerErrorCode, + "message": "Internal server error", + }) + } + } + } +} diff --git a/api/http/router.go b/api/http/router.go index 6cf7782..728b89f 100644 --- a/api/http/router.go +++ b/api/http/router.go @@ -1,6 +1,10 @@ package http import ( + "net/http" + "time" + + "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" "github.com/gin-gonic/gin/binding" "github.com/go-playground/validator/v10" @@ -8,13 +12,22 @@ import ( "go-hexagonal/api/http/middleware" "go-hexagonal/api/http/validator/custom" "go-hexagonal/config" + "go-hexagonal/domain/service" +) + +// Service instances for API handlers +var ( + services *service.Services ) +// RegisterServices registers service instances for API handlers +func RegisterServices(s *service.Services) { + services = s +} + // NewServerRoute creates and configures the HTTP server routes func NewServerRoute() *gin.Engine { - if config.GlobalConfig.App.Debug { - gin.SetMode(gin.DebugMode) - } else { + if config.GlobalConfig.Env.IsProd() { gin.SetMode(gin.ReleaseMode) } @@ -31,10 +44,11 @@ func NewServerRoute() *gin.Engine { router.Use(middleware.Cors()) router.Use(middleware.RequestLogger()) // Add request logging middleware router.Use(middleware.Translations()) + router.Use(middleware.ErrorHandlerMiddleware()) // Add unified error handling middleware // Health check router.GET("/ping", func(c *gin.Context) { - c.String(200, "pong") + c.String(http.StatusOK, "pong") }) // Debug tools @@ -42,6 +56,16 @@ func NewServerRoute() *gin.Engine { middleware.RegisterPprof(router) } + // Configure CORS + router.Use(cors.New(cors.Config{ + AllowOrigins: []string{"*"}, + AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}, + AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization"}, + ExposeHeaders: []string{"Content-Length"}, + AllowCredentials: true, + MaxAge: 12 * time.Hour, + })) + // Unified API version api := router.Group("/api") { diff --git a/api/http/validator/custom/validator.go b/api/http/validator/custom/validator.go index 75bd4c4..be780d7 100644 --- a/api/http/validator/custom/validator.go +++ b/api/http/validator/custom/validator.go @@ -6,6 +6,12 @@ import ( "github.com/go-playground/validator/v10" ) +// Min password length constant +const ( + // MinPasswordLength defines the minimum length for password validation + MinPasswordLength = 8 +) + // RegisterValidators registers custom validators func RegisterValidators(v *validator.Validate) { // Register phone number validator @@ -47,6 +53,6 @@ func validatePassword(fl validator.FieldLevel) bool { hasLower := regexp.MustCompile(`[a-z]`).MatchString(password) hasNumber := regexp.MustCompile(`[0-9]`).MatchString(password) hasSpecial := regexp.MustCompile(`[!@#$%^&*]`).MatchString(password) - hasLength := len(password) >= 8 + hasLength := len(password) >= MinPasswordLength return hasUpper && hasLower && hasNumber && hasSpecial && hasLength } diff --git a/application/core/errors.go b/application/core/errors.go index bc533a3..0462d05 100644 --- a/application/core/errors.go +++ b/application/core/errors.go @@ -3,6 +3,15 @@ package core import ( "errors" "fmt" + + apperrors "go-hexagonal/util/errors" +) + +// HTTP status codes as registered with IANA +const ( + StatusBadRequest = 400 // RFC 7231, 6.5.1 + StatusNotFound = 404 // RFC 7231, 6.5.4 + StatusInternalServerError = 500 // RFC 7231, 6.6.1 ) // Error types @@ -15,7 +24,7 @@ const ( ErrorTypeInternal = "INTERNAL_ERROR" ) -// Application layer error definitions +// Compatibility errors for backward compatibility var ( ErrInvalidInput = errors.New("invalid input") ErrNotFound = errors.New("resource not found") @@ -25,7 +34,7 @@ var ( ErrInternal = errors.New("internal error") ) -// Error represents an application error +// Error represents an application error (will be deprecated in favor of util/errors) type Error struct { Type string // Error type Code int // Error code @@ -47,6 +56,7 @@ func (e *Error) Unwrap() error { } // NewError creates a new application error +// Deprecated: Use util/errors package instead func NewError(errorType string, code int, message string, err error) *Error { return &Error{ Type: errorType, @@ -57,31 +67,51 @@ func NewError(errorType string, code int, message string, err error) *Error { } // NewValidationError creates a validation error +// Deprecated: Use util/errors.NewValidationError instead func NewValidationError(code int, message string, err error) *Error { - return NewError(ErrorTypeValidation, code, message, err) + return NewError("VALIDATION_ERROR", code, message, err) } // NewNotFoundError creates a not found error +// Deprecated: Use util/errors.NewNotFoundError instead func NewNotFoundError(code int, message string, err error) *Error { - return NewError(ErrorTypeNotFound, code, message, err) + return NewError("NOT_FOUND", code, message, err) } // NewUnauthorizedError creates an unauthorized error +// Deprecated: Use util/errors package instead func NewUnauthorizedError(code int, message string, err error) *Error { - return NewError(ErrorTypeUnauthorized, code, message, err) + return NewError("UNAUTHORIZED", code, message, err) } // NewForbiddenError creates a forbidden error +// Deprecated: Use util/errors package instead func NewForbiddenError(code int, message string, err error) *Error { - return NewError(ErrorTypeForbidden, code, message, err) + return NewError("FORBIDDEN", code, message, err) } // NewConflictError creates a conflict error +// Deprecated: Use util/errors package instead func NewConflictError(code int, message string, err error) *Error { - return NewError(ErrorTypeConflict, code, message, err) + return NewError("CONFLICT", code, message, err) } // NewInternalError creates an internal error +// Deprecated: Use util/errors.NewSystemError instead func NewInternalError(code int, message string, err error) *Error { - return NewError(ErrorTypeInternal, code, message, err) + return NewError("INTERNAL_ERROR", code, message, err) +} + +// ToAppError converts a legacy Error to the new AppError type +func ToAppError(err *Error) *apperrors.AppError { + switch err.Type { + case "VALIDATION_ERROR": + return apperrors.NewValidationError(err.Message, err.Err) + case "NOT_FOUND": + return apperrors.NewNotFoundError(err.Message, err.Err) + case "INTERNAL_ERROR": + return apperrors.NewSystemError(err.Message, err.Err) + default: + return apperrors.New(apperrors.ErrorTypeSystem, err.Message) + } } diff --git a/application/example/create_example.go b/application/example/create_example.go index 81b55fb..8457f66 100644 --- a/application/example/create_example.go +++ b/application/example/create_example.go @@ -7,11 +7,12 @@ import ( "go-hexagonal/application/core" "go-hexagonal/domain/model" "go-hexagonal/domain/service" + "go-hexagonal/util/errors" ) // CreateExampleInput represents input for creating an example type CreateExampleInput struct { - Name string `json:"name"` + Name string `json:"name" validate:"required"` Alias string `json:"alias"` } @@ -38,7 +39,12 @@ func NewCreateExampleHandler(exampleService *service.ExampleService) *CreateExam func (h *CreateExampleHandler) Handle(ctx context.Context, input interface{}) (interface{}, error) { createInput, ok := input.(CreateExampleInput) if !ok { - return nil, core.ErrInvalidInput + return nil, errors.NewValidationError("Invalid input data", core.ErrInvalidInput) + } + + // Validate input + if createInput.Name == "" { + return nil, errors.NewValidationError("Name cannot be empty", nil) } example := &model.Example{ @@ -48,7 +54,8 @@ func (h *CreateExampleHandler) Handle(ctx context.Context, input interface{}) (i createdExample, err := h.ExampleService.Create(ctx, example) if err != nil { - return nil, err + // Wrap domain error as application error + return nil, errors.NewPersistenceError("Failed to create example", err) } return CreateExampleOutput{ diff --git a/application/example/delete_example.go b/application/example/delete_example.go index db12140..076807f 100644 --- a/application/example/delete_example.go +++ b/application/example/delete_example.go @@ -2,6 +2,7 @@ package example import ( "context" + "errors" "go-hexagonal/application/core" "go-hexagonal/domain/service" @@ -12,6 +13,11 @@ type DeleteExampleInput struct { ID int `json:"id" validate:"required"` } +// DeleteExampleOutput represents output for deleting an example +type DeleteExampleOutput struct { + Success bool `json:"success"` +} + // DeleteExampleHandler handles example deletion type DeleteExampleHandler struct { ExampleService *service.ExampleService @@ -28,18 +34,18 @@ func NewDeleteExampleHandler(exampleService *service.ExampleService) *DeleteExam func (h *DeleteExampleHandler) Handle(ctx context.Context, input interface{}) (interface{}, error) { deleteInput, ok := input.(DeleteExampleInput) if !ok { - return nil, core.NewValidationError(400, "invalid input type", core.ErrInvalidInput) + return nil, core.NewValidationError(core.StatusBadRequest, "invalid input type", core.ErrInvalidInput) } - // Delete example - if err := h.ExampleService.Delete(ctx, deleteInput.ID); err != nil { - if err == core.ErrNotFound { - return nil, core.NewNotFoundError(404, "example not found", err) + err := h.ExampleService.Delete(ctx, deleteInput.ID) + if err != nil { + if errors.Is(err, core.ErrNotFound) { + return nil, core.NewNotFoundError(core.StatusNotFound, "example not found", err) } - return nil, core.NewInternalError(500, "failed to delete example", err) + return nil, core.NewInternalError(core.StatusInternalServerError, "failed to delete example", err) } - return nil, nil + return DeleteExampleOutput{Success: true}, nil } // DeleteExampleUseCase represents the use case for deleting examples diff --git a/application/example/find_example_by_name.go b/application/example/find_example_by_name.go index f05313b..aa6e78a 100644 --- a/application/example/find_example_by_name.go +++ b/application/example/find_example_by_name.go @@ -35,15 +35,15 @@ func NewFindExampleByNameHandler(exampleService *service.ExampleService) *FindEx func (h *FindExampleByNameHandler) Handle(ctx context.Context, input interface{}) (interface{}, error) { findInput, ok := input.(FindExampleByNameInput) if !ok { - return nil, core.NewValidationError(400, "invalid input type", core.ErrInvalidInput) + return nil, core.NewValidationError(core.StatusBadRequest, "invalid input type", core.ErrInvalidInput) } example, err := h.ExampleService.FindByName(ctx, findInput.Name) if err != nil { - return nil, core.NewInternalError(500, "failed to find example by name", err) + return nil, core.NewInternalError(core.StatusInternalServerError, "failed to find example by name", err) } if example == nil { - return nil, core.NewNotFoundError(404, "example not found", core.ErrNotFound) + return nil, core.NewNotFoundError(core.StatusNotFound, "example not found", core.ErrNotFound) } return FindExampleByNameOutput{ diff --git a/application/example/update_example.go b/application/example/update_example.go index a93b50b..0cb4e40 100644 --- a/application/example/update_example.go +++ b/application/example/update_example.go @@ -38,27 +38,28 @@ func NewUpdateExampleHandler(exampleService *service.ExampleService) *UpdateExam func (h *UpdateExampleHandler) Handle(ctx context.Context, input interface{}) (interface{}, error) { updateInput, ok := input.(UpdateExampleInput) if !ok { - return nil, core.NewValidationError(400, "invalid input type", core.ErrInvalidInput) + return nil, core.NewValidationError(core.StatusBadRequest, "invalid input type", core.ErrInvalidInput) } // Check if example exists - existingExample, err := h.ExampleService.Get(ctx, updateInput.ID) + _, err := h.ExampleService.Get(ctx, updateInput.ID) if err != nil { - return nil, core.NewInternalError(500, "failed to check example existence", err) + return nil, core.NewInternalError(core.StatusInternalServerError, "failed to check example existence", err) } - if existingExample == nil { - return nil, core.NewNotFoundError(404, "example not found", core.ErrNotFound) + if err == core.ErrNotFound { + return nil, core.NewNotFoundError(core.StatusNotFound, "example not found", core.ErrNotFound) } - // Update example + // Create domain model from input example := &model.Example{ Id: updateInput.ID, Name: updateInput.Name, Alias: updateInput.Alias, } + // Update example - ExampleService.Update only returns error if err := h.ExampleService.Update(ctx, example); err != nil { - return nil, core.NewInternalError(500, "failed to update example", err) + return nil, core.NewInternalError(core.StatusInternalServerError, "failed to update example", err) } return UpdateExampleOutput{ diff --git a/cmd/http_server/http_server.go b/cmd/http_server/http_server.go index 8b9d4ab..0d3c84c 100644 --- a/cmd/http_server/http_server.go +++ b/cmd/http_server/http_server.go @@ -6,21 +6,16 @@ import ( "github.com/spf13/cast" - "go-hexagonal/adapter/dependency" http2 "go-hexagonal/api/http" "go-hexagonal/config" + "go-hexagonal/domain/service" "go-hexagonal/util/log" ) // Start initializes and starts the HTTP server -func Start(ctx context.Context, errChan chan error, httpCloseCh chan struct{}) { - // Initialize services using dependency injection - _, err := dependency.InitializeServices(ctx) - if err != nil { - log.SugaredLogger.Errorf("Failed to initialize services: %v", err) - errChan <- err - return - } +func Start(ctx context.Context, errChan chan error, httpCloseCh chan struct{}, services *service.Services) { + // Register services for API handlers to use + http2.RegisterServices(services) // Initialize server srv := &http.Server{ diff --git a/cmd/main.go b/cmd/main.go index 15da334..9582d6e 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -8,16 +8,21 @@ import ( "syscall" "time" + "go-hexagonal/adapter/dependency" "go-hexagonal/adapter/repository" - "go-hexagonal/adapter/repository/mysql/entity" "go-hexagonal/cmd/http_server" "go-hexagonal/config" - "go-hexagonal/domain/service" "go-hexagonal/util/log" ) const ServiceName = "go-hexagonal" +// Constants for application settings +const ( + // DefaultShutdownTimeout is the default timeout for graceful shutdown + DefaultShutdownTimeout = 5 * time.Second +) + func main() { fmt.Println("Starting " + ServiceName) @@ -41,15 +46,12 @@ func main() { ) fmt.Println("Repositories initialized") - // Initialize services + // Initialize services using dependency injection fmt.Println("Initializing services...") - service.Init(ctx) - - // Inject entity layer dependencies - if service.ExampleSvc.Repository == nil { - service.ExampleSvc.Repository = entity.NewExample() + services, err := dependency.InitializeServices(ctx) + if err != nil { + log.SugaredLogger.Fatalf("Failed to initialize services: %v", err) } - fmt.Println("Services initialized") // Create error channel and HTTP close channel @@ -58,7 +60,7 @@ func main() { // Start HTTP server fmt.Println("Starting HTTP server...") - go http_server.Start(ctx, errChan, httpCloseCh) + go http_server.Start(ctx, errChan, httpCloseCh, services) fmt.Println("HTTP server started") // Listen for signals @@ -78,17 +80,17 @@ func main() { cancel() // Set shutdown timeout - shutdownTimeout := 5 * time.Second + shutdownTimeout := DefaultShutdownTimeout shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), shutdownTimeout) defer shutdownCancel() - // Wait for HTTP server to close or timeout + // Wait for HTTP server to close select { case <-httpCloseCh: - log.SugaredLogger.Info("HTTP server shutdown complete") + log.SugaredLogger.Info("HTTP server shutdown completed") case <-shutdownCtx.Done(): log.SugaredLogger.Warn("HTTP server shutdown timed out") } - log.SugaredLogger.Info("Server exited") + log.SugaredLogger.Info("Server gracefully stopped") } diff --git a/config/config.go b/config/config.go index 67de977..a04fedb 100644 --- a/config/config.go +++ b/config/config.go @@ -11,6 +11,11 @@ import ( "github.com/spf13/viper" ) +// Constants +const ( + TrueStr = "true" // String representation of boolean true value +) + type Env string func (e Env) IsProd() bool { @@ -118,6 +123,22 @@ func Load(configPath string, configFile string) (*Config, error) { // applyEnvOverrides applies environment variable overrides to the configuration func applyEnvOverrides(conf *Config) { + // Apply config overrides by category + applyAppEnvOverrides(conf) + applyHTTPServerEnvOverrides(conf) + applyMySQLEnvOverrides(conf) + applyPostgresEnvOverrides(conf) + applyRedisEnvOverrides(conf) + applyLogEnvOverrides(conf) + + // Migration directory + if migrationDir := os.Getenv("APP_MIGRATION_DIR"); migrationDir != "" { + conf.MigrationDir = migrationDir + } +} + +// applyAppEnvOverrides applies App related environment variables +func applyAppEnvOverrides(conf *Config) { // Environment if env := os.Getenv("APP_ENV"); env != "" { conf.Env = Env(env) @@ -128,18 +149,20 @@ func applyEnvOverrides(conf *Config) { conf.App.Name = name } if debug := os.Getenv("APP_APP_DEBUG"); debug != "" { - conf.App.Debug = debug == "true" + conf.App.Debug = debug == TrueStr } if version := os.Getenv("APP_APP_VERSION"); version != "" { conf.App.Version = version } +} - // HTTP Server +// applyHTTPServerEnvOverrides applies HTTP server related environment variables +func applyHTTPServerEnvOverrides(conf *Config) { if addr := os.Getenv("APP_HTTP_SERVER_ADDR"); addr != "" { conf.HTTPServer.Addr = addr } if pprof := os.Getenv("APP_HTTP_SERVER_PPROF"); pprof != "" { - conf.HTTPServer.Pprof = pprof == "true" + conf.HTTPServer.Pprof = pprof == TrueStr } if pageSize := os.Getenv("APP_HTTP_SERVER_DEFAULT_PAGE_SIZE"); pageSize != "" { if val, err := strconv.Atoi(pageSize); err == nil { @@ -157,8 +180,10 @@ func applyEnvOverrides(conf *Config) { if writeTimeout := os.Getenv("APP_HTTP_SERVER_WRITE_TIMEOUT"); writeTimeout != "" { conf.HTTPServer.WriteTimeout = writeTimeout } +} - // MySQL +// applyMySQLEnvOverrides applies MySQL related environment variables +func applyMySQLEnvOverrides(conf *Config) { if host := os.Getenv("APP_MYSQL_HOST"); host != "" { conf.MySQL.Host = host } @@ -176,8 +201,62 @@ func applyEnvOverrides(conf *Config) { if database := os.Getenv("APP_MYSQL_DATABASE"); database != "" { conf.MySQL.Database = database } + if maxIdleConns := os.Getenv("APP_MYSQL_MAX_IDLE_CONNS"); maxIdleConns != "" { + if val, err := strconv.Atoi(maxIdleConns); err == nil { + conf.MySQL.MaxIdleConns = val + } + } + if maxOpenConns := os.Getenv("APP_MYSQL_MAX_OPEN_CONNS"); maxOpenConns != "" { + if val, err := strconv.Atoi(maxOpenConns); err == nil { + conf.MySQL.MaxOpenConns = val + } + } + if maxLifeTime := os.Getenv("APP_MYSQL_MAX_LIFE_TIME"); maxLifeTime != "" { + conf.MySQL.MaxLifeTime = maxLifeTime + } + if maxIdleTime := os.Getenv("APP_MYSQL_MAX_IDLE_TIME"); maxIdleTime != "" { + conf.MySQL.MaxIdleTime = maxIdleTime + } + if charSet := os.Getenv("APP_MYSQL_CHAR_SET"); charSet != "" { + conf.MySQL.CharSet = charSet + } + if parseTime := os.Getenv("APP_MYSQL_PARSE_TIME"); parseTime != "" { + conf.MySQL.ParseTime = parseTime == TrueStr + } + if timeZone := os.Getenv("APP_MYSQL_TIME_ZONE"); timeZone != "" { + conf.MySQL.TimeZone = timeZone + } +} - // Redis +// applyPostgresEnvOverrides applies PostgreSQL related environment variables +func applyPostgresEnvOverrides(conf *Config) { + if host := os.Getenv("APP_POSTGRES_HOST"); host != "" { + conf.Postgre.Host = host + } + if port := os.Getenv("APP_POSTGRES_PORT"); port != "" { + if val, err := strconv.Atoi(port); err == nil { + conf.Postgre.Port = val + } + } + if username := os.Getenv("APP_POSTGRES_USERNAME"); username != "" { + conf.Postgre.Username = username + } + if password := os.Getenv("APP_POSTGRES_PASSWORD"); password != "" { + conf.Postgre.Password = password + } + if dbName := os.Getenv("APP_POSTGRES_DB_NAME"); dbName != "" { + conf.Postgre.DbName = dbName + } + if sslMode := os.Getenv("APP_POSTGRES_SSL_MODE"); sslMode != "" { + conf.Postgre.SSLMode = sslMode + } + if timeZone := os.Getenv("APP_POSTGRES_TIME_ZONE"); timeZone != "" { + conf.Postgre.TimeZone = timeZone + } +} + +// applyRedisEnvOverrides applies Redis related environment variables +func applyRedisEnvOverrides(conf *Config) { if host := os.Getenv("APP_REDIS_HOST"); host != "" { conf.Redis.Host = host } @@ -194,24 +273,46 @@ func applyEnvOverrides(conf *Config) { conf.Redis.DB = val } } + if poolSize := os.Getenv("APP_REDIS_POOL_SIZE"); poolSize != "" { + if val, err := strconv.Atoi(poolSize); err == nil { + conf.Redis.PoolSize = val + } + } + if idleTimeout := os.Getenv("APP_REDIS_IDLE_TIMEOUT"); idleTimeout != "" { + if val, err := strconv.Atoi(idleTimeout); err == nil { + conf.Redis.IdleTimeout = val + } + } + if minIdleConns := os.Getenv("APP_REDIS_MIN_IDLE_CONNS"); minIdleConns != "" { + if val, err := strconv.Atoi(minIdleConns); err == nil { + conf.Redis.MinIdleConns = val + } + } +} - // PostgreSQL - if host := os.Getenv("APP_POSTGRES_HOST"); host != "" { - conf.Postgre.Host = host +// applyLogEnvOverrides applies Log related environment variables +func applyLogEnvOverrides(conf *Config) { + if savePath := os.Getenv("APP_LOG_SAVE_PATH"); savePath != "" { + conf.Log.SavePath = savePath } - if port := os.Getenv("APP_POSTGRES_PORT"); port != "" { - if val, err := strconv.Atoi(port); err == nil { - conf.Postgre.Port = val + if fileName := os.Getenv("APP_LOG_FILE_NAME"); fileName != "" { + conf.Log.FileName = fileName + } + if maxSize := os.Getenv("APP_LOG_MAX_SIZE"); maxSize != "" { + if val, err := strconv.Atoi(maxSize); err == nil { + conf.Log.MaxSize = val } } - if username := os.Getenv("APP_POSTGRES_USERNAME"); username != "" { - conf.Postgre.Username = username + if maxAge := os.Getenv("APP_LOG_MAX_AGE"); maxAge != "" { + if val, err := strconv.Atoi(maxAge); err == nil { + conf.Log.MaxAge = val + } } - if password := os.Getenv("APP_POSTGRES_PASSWORD"); password != "" { - conf.Postgre.Password = password + if localTime := os.Getenv("APP_LOG_LOCAL_TIME"); localTime != "" { + conf.Log.LocalTime = localTime == TrueStr } - if dbName := os.Getenv("APP_POSTGRES_DB_NAME"); dbName != "" { - conf.Postgre.DbName = dbName + if compress := os.Getenv("APP_LOG_COMPRESS"); compress != "" { + conf.Log.Compress = compress == TrueStr } } diff --git a/domain/service/main_test.go b/domain/service/main_test.go index d43b076..b3699e0 100644 --- a/domain/service/main_test.go +++ b/domain/service/main_test.go @@ -12,12 +12,15 @@ import ( var ctx = context.TODO() func TestMain(m *testing.M) { + // Initialize configuration and logging config.Init("../../config", "config") log.Init() + // Initialize repositories for testing repository.Init( repository.WithMySQL(), repository.WithRedis(), ) + m.Run() } diff --git a/domain/service/service.go b/domain/service/service.go index 620fe5a..ea50288 100644 --- a/domain/service/service.go +++ b/domain/service/service.go @@ -1,19 +1,10 @@ package service import ( - "context" - "sync" - "go-hexagonal/domain/event" "go-hexagonal/domain/repo" ) -var ( - once sync.Once - ExampleSvc *ExampleService - EventBus event.EventBus -) - // ExampleRepoFactory defines the interface for example repository factory type ExampleRepoFactory interface { CreateExampleRepo() repo.IExampleRepo @@ -32,23 +23,3 @@ func NewServices(exampleService *ExampleService, eventBus event.EventBus) *Servi EventBus: eventBus, } } - -// Init initializes services (legacy method for backward compatibility) -// Note: This method is deprecated, services should be initialized through dependency injection -func Init(ctx context.Context) { - // This method is deprecated, new code should use dependency injection - once.Do(func() { - // Initialize event bus - EventBus = event.NewInMemoryEventBus() - - // Register event handlers - loggingHandler := event.NewLoggingEventHandler() - exampleHandler := event.NewExampleEventHandler() - EventBus.Subscribe(loggingHandler) - EventBus.Subscribe(exampleHandler) - - // Service instances will be injected by the infrastructure layer - ExampleSvc = NewExampleService(nil) - ExampleSvc.EventBus = EventBus - }) -} diff --git a/go.mod b/go.mod index 9138810..d4c67b9 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module go-hexagonal -go 1.24.1 +go 1.24 require ( github.com/DATA-DOG/go-sqlmock v1.5.2 diff --git a/util/errors/errors.go b/util/errors/errors.go new file mode 100644 index 0000000..b097dac --- /dev/null +++ b/util/errors/errors.go @@ -0,0 +1,138 @@ +// Package errors provides unified error creation and handling methods +package errors + +import ( + "fmt" + + stderrors "errors" +) + +// ErrorType defines the error type +type ErrorType string + +const ( + // ErrorTypeValidation represents validation errors + ErrorTypeValidation ErrorType = "VALIDATION" + // ErrorTypeNotFound represents resource not found errors + ErrorTypeNotFound ErrorType = "NOT_FOUND" + // ErrorTypePersistence represents persistence layer errors + ErrorTypePersistence ErrorType = "PERSISTENCE" + // ErrorTypeSystem represents internal system errors + ErrorTypeSystem ErrorType = "SYSTEM" +) + +// AppError defines the application error structure +type AppError struct { + Type ErrorType + Message string + Cause error + Details map[string]interface{} +} + +// Error implements the error interface +func (e *AppError) Error() string { + if e.Cause != nil { + return fmt.Sprintf("%s: %s (caused by: %v)", e.Type, e.Message, e.Cause) + } + return fmt.Sprintf("%s: %s", e.Type, e.Message) +} + +// Unwrap supports Go 1.13+ error unwrapping +func (e *AppError) Unwrap() error { + return e.Cause +} + +// WithDetails adds error details +func (e *AppError) WithDetails(details map[string]interface{}) *AppError { + e.Details = details + return e +} + +// NewValidationError creates a validation error +func NewValidationError(message string, cause error) *AppError { + return &AppError{ + Type: ErrorTypeValidation, + Message: message, + Cause: cause, + } +} + +// NewNotFoundError creates a resource not found error +func NewNotFoundError(message string, cause error) *AppError { + return &AppError{ + Type: ErrorTypeNotFound, + Message: message, + Cause: cause, + } +} + +// NewPersistenceError creates a persistence error +func NewPersistenceError(message string, cause error) *AppError { + return &AppError{ + Type: ErrorTypePersistence, + Message: message, + Cause: cause, + } +} + +// NewSystemError creates a system error +func NewSystemError(message string, cause error) *AppError { + return &AppError{ + Type: ErrorTypeSystem, + Message: message, + Cause: cause, + } +} + +// IsValidationError checks if the error is a validation error +func IsValidationError(err error) bool { + var appErr *AppError + if stderrors.As(err, &appErr) { + return appErr.Type == ErrorTypeValidation + } + return false +} + +// IsNotFoundError checks if the error is a resource not found error +func IsNotFoundError(err error) bool { + var appErr *AppError + if stderrors.As(err, &appErr) { + return appErr.Type == ErrorTypeNotFound + } + return false +} + +// IsPersistenceError checks if the error is a persistence error +func IsPersistenceError(err error) bool { + var appErr *AppError + if stderrors.As(err, &appErr) { + return appErr.Type == ErrorTypePersistence + } + return false +} + +// IsSystemError checks if the error is a system error +func IsSystemError(err error) bool { + var appErr *AppError + if stderrors.As(err, &appErr) { + return appErr.Type == ErrorTypeSystem + } + return false +} + +// Wrap wraps a standard error as an application error +func Wrap(err error, errType ErrorType, message string) *AppError { + return &AppError{ + Type: errType, + Message: message, + Cause: err, + } +} + +// New creates a new application error +func New(errType ErrorType, message string) *AppError { + return &AppError{ + Type: errType, + Message: message, + } +}