├── .editorconfig ├── .github ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── ci.yaml ├── .gitignore ├── .golangci.yml ├── .pre-commit-config.yaml ├── CODING_STYLE.md ├── LICENSE ├── Makefile ├── README.md ├── README_CN.md ├── adapter ├── amqp │ └── kafka.go ├── converter │ ├── example_converter.go │ └── example_converter_test.go ├── dependency │ ├── grpc │ │ └── grpc.go │ ├── http │ │ └── http.go │ ├── wire.go │ └── wire_gen.go ├── job │ └── job.go ├── repo │ └── transaction_factory.go └── repository │ ├── DDL.sql │ ├── error.go │ ├── factory.go │ ├── main_test.go │ ├── mysql │ ├── client.go │ ├── entity │ │ └── example.go │ ├── example_repo.go │ ├── testcontainer.go │ └── testcontainer_test.go │ ├── postgre │ ├── client.go │ ├── example_repo.go │ ├── testcontainer.go │ └── testcontainer_test.go │ ├── redis │ ├── client.go │ ├── enhanced_cache.go │ ├── example_cache.go │ ├── example_cache_test.go │ ├── testcontainer.go │ └── testcontainer_test.go │ ├── repository.go │ ├── repository_plan.md │ ├── repository_test.go │ └── transaction.go ├── api ├── dto │ ├── dto.go │ └── example.go ├── error_code │ └── error_code.go ├── grpc │ └── grpc_server.go ├── http │ ├── converter.go │ ├── example.go │ ├── example_test.go │ ├── handle │ │ ├── handle.go │ │ ├── handle_test.go │ │ └── main_test.go │ ├── main_test.go │ ├── middleware │ │ ├── cors.go │ │ ├── error_handler.go │ │ ├── main_test.go │ │ ├── middleware.go │ │ ├── middleware_test.go │ │ ├── pprof.go │ │ ├── request_id.go │ │ ├── request_logger.go │ │ └── translations.go │ ├── paginate │ │ └── paginate.go │ ├── router.go │ └── validator │ │ ├── custom │ │ ├── validator.go │ │ └── validator_test.go │ │ └── validator.go └── middleware │ └── metrics.go ├── application ├── core │ ├── interfaces.go │ └── monitored.go ├── example │ ├── create.go │ ├── create_test.go │ ├── delete.go │ ├── delete_test.go │ ├── dto.go │ ├── find_by_name.go │ ├── find_example_by_name_test.go │ ├── get.go │ ├── get_test.go │ ├── main_test.go │ ├── mocks │ │ └── service.go │ ├── test_utils.go │ ├── update.go │ └── update_test.go └── factory.go ├── cmd ├── cmd ├── http_server │ └── http_server.go └── main.go ├── commitlint.config.js ├── config ├── config.go ├── config.yaml └── config_test.go ├── domain ├── aggregate │ └── aggregate.go ├── event │ ├── async_event_bus.go │ ├── event.go │ ├── event_bus.go │ ├── event_bus_test.go │ ├── event_test.go │ ├── example_events.go │ └── example_handlers.go ├── model │ ├── errors.go │ ├── example.go │ └── example_test.go ├── repo │ ├── error.go │ ├── example.go │ ├── transaction.go │ └── transaction_factory.go ├── service │ ├── converter.go │ ├── example.go │ ├── example_test.go │ ├── iexample_service.go │ ├── main_test.go │ └── service.go └── vo │ └── vo.go ├── go.mod ├── go.sum ├── tests ├── main_test.go ├── migrations │ ├── 000001_create_user_table.down.sql │ ├── 000001_create_user_table.up.sql │ └── migrate │ │ └── migrate.go ├── mysql.go ├── mysql_example_test.go ├── postgresql.go ├── postgresql_example_test.go ├── redis.go └── redis_example_test.go └── util ├── clean_arch ├── clean_arch.go ├── clean_arch_test.go └── main_test.go ├── convert.go ├── errors └── errors.go ├── log ├── logger.go ├── logger_test.go └── logger_usage_examples.go ├── metrics └── metrics.go └── util.go /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.go] 12 | indent_style = space 13 | indent_size = 4 14 | 15 | [*.{json,yml,yaml}] 16 | indent_size = 2 17 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | ## Changes Description 12 | 20 | 21 | 22 | 39 | 40 | 41 | 48 | 49 | 50 | 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/intellij,java 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=intellij,java 3 | 4 | ### Intellij ### 5 | .idea/ 6 | 7 | # Gradle and Maven with auto-import 8 | # When using Gradle or Maven with auto-import, you should exclude module files, 9 | # since they will be recreated, and may cause churn. Uncomment if using 10 | # auto-import. 11 | # .idea/artifacts 12 | # .idea/compiler.xml 13 | # .idea/jarRepositories.xml 14 | # .idea/modules.xml 15 | # .idea/*.iml 16 | # .idea/modules 17 | # *.iml 18 | # *.ipr 19 | 20 | # CMake 21 | cmake-build-*/ 22 | 23 | # Mongo Explorer plugin 24 | .idea/**/mongoSettings.xml 25 | 26 | # File-based project format 27 | *.iws 28 | 29 | # IntelliJ 30 | out/ 31 | 32 | # mpeltonen/sbt-idea plugin 33 | .idea_modules/ 34 | 35 | # JIRA plugin 36 | atlassian-ide-plugin.xml 37 | 38 | # Cursive Clojure plugin 39 | .idea/replstate.xml 40 | 41 | # Crashlytics plugin (for Android Studio and IntelliJ) 42 | com_crashlytics_export_strings.xml 43 | crashlytics.properties 44 | crashlytics-build.properties 45 | fabric.properties 46 | 47 | # Editor-based Rest Client 48 | .idea/httpRequests 49 | 50 | # Android studio 3.1+ serialized cache file 51 | .idea/caches/build_file_checksums.ser 52 | 53 | ### Java ### 54 | # Compiled class file 55 | *.class 56 | 57 | # Log file 58 | *.log 59 | 60 | # BlueJ files 61 | *.ctxt 62 | 63 | # Mobile Tools for Java (J2ME) 64 | .mtj.tmp/ 65 | 66 | # Package Files # 67 | *.jar 68 | *.war 69 | *.nar 70 | *.ear 71 | *.zip 72 | *.tar.gz 73 | *.rar 74 | 75 | # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml 76 | hs_err_pid* 77 | 78 | # End of https://www.toptal.com/developers/gitignore/api/intellij,java 79 | 80 | 81 | ############ 82 | ############ 83 | ############ 84 | 85 | 86 | # Created by https://www.toptal.com/developers/gitignore/api/intellij,go 87 | # Edit at https://www.toptal.com/developers/gitignore?templates=intellij,go 88 | 89 | ### Go ### 90 | # If you prefer the allow list template instead of the deny list, see community template: 91 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 92 | # 93 | # Binaries for programs and plugins 94 | *.exe 95 | *.exe~ 96 | *.dll 97 | *.so 98 | *.dylib 99 | 100 | # Test binary, built with `go test -c` 101 | *.test 102 | 103 | # Output of the go coverage tool, specifically when used with LiteIDE 104 | *.out 105 | 106 | # Dependency directories (remove the comment below to include it) 107 | # vendor/ 108 | 109 | # Go workspace file 110 | go.work 111 | 112 | config/config.private.yaml 113 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | linters: 2 | fast: true 3 | enable-all: true 4 | disable: 5 | - wrapcheck 6 | - testpackage 7 | - tagliatelle 8 | - nlreturn 9 | - funlen 10 | - gofumpt 11 | - gochecknoglobals 12 | - gocognit 13 | - godox 14 | - lll 15 | - wsl 16 | - forbidigo 17 | - godot 18 | - nestif 19 | - gci 20 | - dogsled 21 | - gochecknoinits 22 | - depguard 23 | - cyclop 24 | - nosprintfhostport 25 | - mnd 26 | - tagalign 27 | 28 | linters-settings: 29 | goimports: 30 | local-prefixes: go-hexagonal 31 | revive: 32 | rules: 33 | - name: var-naming 34 | - name: exported 35 | arguments: 36 | - "disableStutteringCheck" 37 | - name: package-comments 38 | - name: dot-imports 39 | - name: blank-imports 40 | - name: context-keys-type 41 | - name: context-as-argument 42 | - name: error-return 43 | - name: error-strings 44 | - name: error-naming 45 | - name: increment-decrement 46 | - name: var-declaration 47 | - name: range 48 | - name: receiver-naming 49 | - name: time-naming 50 | - name: indent-error-flow 51 | - name: empty-block 52 | - name: superfluous-else 53 | - name: modifies-parameter 54 | - name: unreachable-code 55 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v5.0.0 4 | hooks: 5 | - id: trailing-whitespace 6 | - id: end-of-file-fixer 7 | - id: check-yaml 8 | - id: check-toml 9 | - id: check-json 10 | - id: pretty-format-json 11 | # - id: check-added-large-files 12 | - repo: https://github.com/dnephin/pre-commit-golang 13 | rev: v0.5.1 14 | hooks: 15 | - id: go-fmt 16 | - id: go-imports 17 | - id: go-unit-tests 18 | - id: go-build 19 | - id: go-mod-tidy 20 | - repo: https://github.com/detailyang/pre-commit-shell 21 | rev: 1.0.5 22 | hooks: 23 | - id: shell-lint 24 | - repo: https://github.com/alessandrojcm/commitlint-pre-commit-hook 25 | rev: v9.20.0 26 | hooks: 27 | - id: commitlint 28 | stages: [commit-msg] 29 | additional_dependencies: ['@commitlint/config-conventional'] 30 | - repo: https://github.com/golangci/golangci-lint 31 | rev: v1.64.8 32 | hooks: 33 | - id: golangci-lint 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | Copyright 2025 Rancho Cooper 8 | 9 | Licensed under the Apache License, Version 2.0 (the "License"); 10 | you may not use this file except in compliance with the License. 11 | You may obtain a copy of the License at 12 | 13 | http://www.apache.org/licenses/LICENSE-2.0 14 | 15 | Unless required by applicable law or agreed to in writing, software 16 | distributed under the License is distributed on an "AS IS" BASIS, 17 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 18 | See the License for the specific language governing permissions and 19 | limitations under the License. 20 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: fmt lint test 2 | 3 | init: 4 | @echo "=== 👩‍🌾 Init Go Project with Pre-commit Hooks ===" 5 | brew install go 6 | brew install node 7 | brew install pre-commit 8 | brew install npm 9 | brew install golangci-lint 10 | brew upgrade golangci-lint 11 | npm install -g @commitlint/cli @commitlint/config-conventional 12 | 13 | @echo "=== 🙆 Setup Pre-commit ===" 14 | pre-commit install 15 | @echo "=== ✅ Done. ===" 16 | 17 | fmt: 18 | go fmt ./... 19 | goimports -w -local "go-hexagonal" ./ 20 | 21 | test: 22 | @echo "=== 🦸‍️ Prepare Dependency ===" 23 | go mod tidy 24 | @echo "=== 🦸‍️ Start Unit Test ===" 25 | go test -v -race -cover ./... 26 | 27 | pre-commit.install: 28 | @echo "=== 🙆 Setup Pre-commit ===" 29 | pre-commit install 30 | 31 | precommit.rehook: 32 | @echo "=== 🙆 Rehook Pre-commit ===" 33 | pre-commit autoupdate 34 | pre-commit install --install-hooks 35 | pre-commit install --hook-type commit-msg 36 | 37 | ci.lint: 38 | @echo "=== 🙆 Start CI Linter ===" 39 | golangci-lint run -v ./... --fix 40 | 41 | all: fmt ci.lint test 42 | -------------------------------------------------------------------------------- /adapter/amqp/kafka.go: -------------------------------------------------------------------------------- 1 | package amqp 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "time" 8 | 9 | "github.com/IBM/sarama" 10 | "go.uber.org/zap" 11 | 12 | "go-hexagonal/domain/event" 13 | "go-hexagonal/util/log" 14 | ) 15 | 16 | // KafkaConfig represents Kafka configuration 17 | type KafkaConfig struct { 18 | Brokers []string 19 | Topic string 20 | } 21 | 22 | // KafkaEventBus implements event.EventBus using Kafka 23 | type KafkaEventBus struct { 24 | producer sarama.SyncProducer 25 | topic string 26 | } 27 | 28 | // NewKafkaEventBus creates a new Kafka event bus 29 | func NewKafkaEventBus(cfg *KafkaConfig) (*KafkaEventBus, error) { 30 | config := sarama.NewConfig() 31 | config.Producer.RequiredAcks = sarama.WaitForAll 32 | config.Producer.Retry.Max = 5 33 | config.Producer.Return.Successes = true 34 | 35 | producer, err := sarama.NewSyncProducer(cfg.Brokers, config) 36 | if err != nil { 37 | return nil, fmt.Errorf("failed to create Kafka producer: %w", err) 38 | } 39 | 40 | return &KafkaEventBus{ 41 | producer: producer, 42 | topic: cfg.Topic, 43 | }, nil 44 | } 45 | 46 | // Publish publishes an event to Kafka 47 | func (k *KafkaEventBus) Publish(ctx context.Context, event event.Event) error { 48 | payload, err := json.Marshal(event) 49 | if err != nil { 50 | return fmt.Errorf("failed to marshal event: %w", err) 51 | } 52 | 53 | msg := &sarama.ProducerMessage{ 54 | Topic: k.topic, 55 | Value: sarama.StringEncoder(payload), 56 | Timestamp: time.Now(), 57 | Headers: []sarama.RecordHeader{ 58 | { 59 | Key: []byte("event_name"), 60 | Value: []byte(event.EventName()), 61 | }, 62 | { 63 | Key: []byte("event_id"), 64 | Value: []byte(event.EventID()), 65 | }, 66 | }, 67 | } 68 | 69 | partition, offset, err := k.producer.SendMessage(msg) 70 | if err != nil { 71 | return fmt.Errorf("failed to send message: %w", err) 72 | } 73 | 74 | log.Logger.Info("Event published to Kafka", 75 | zap.String("event_name", event.EventName()), 76 | zap.String("event_id", event.EventID()), 77 | zap.Int32("partition", partition), 78 | zap.Int64("offset", offset), 79 | ) 80 | 81 | return nil 82 | } 83 | 84 | // Close closes the Kafka producer 85 | func (k *KafkaEventBus) Close() error { 86 | if err := k.producer.Close(); err != nil { 87 | return fmt.Errorf("failed to close Kafka producer: %w", err) 88 | } 89 | return nil 90 | } 91 | -------------------------------------------------------------------------------- /adapter/converter/example_converter.go: -------------------------------------------------------------------------------- 1 | package converter 2 | 3 | import ( 4 | "fmt" 5 | 6 | "go-hexagonal/api/dto" 7 | "go-hexagonal/domain/model" 8 | "go-hexagonal/domain/service" 9 | ) 10 | 11 | // ExampleConverter implements the service.Converter interface for Example entities 12 | type ExampleConverter struct{} 13 | 14 | // NewExampleConverter creates a new ExampleConverter 15 | func NewExampleConverter() service.Converter { 16 | return &ExampleConverter{} 17 | } 18 | 19 | // ToExampleResponse converts a domain model to a response object 20 | func (c *ExampleConverter) ToExampleResponse(example *model.Example) (any, error) { 21 | if example == nil { 22 | return nil, fmt.Errorf("example is nil") 23 | } 24 | 25 | return &dto.CreateExampleResp{ 26 | Id: uint(example.Id), 27 | Name: example.Name, 28 | Alias: example.Alias, 29 | CreatedAt: example.CreatedAt, 30 | UpdatedAt: example.UpdatedAt, 31 | }, nil 32 | } 33 | 34 | // FromCreateRequest converts a create request to a domain model 35 | func (c *ExampleConverter) FromCreateRequest(req any) (*model.Example, error) { 36 | createReq, ok := req.(*dto.CreateExampleReq) 37 | if !ok { 38 | return nil, fmt.Errorf("invalid request type: %T", req) 39 | } 40 | 41 | return &model.Example{ 42 | Name: createReq.Name, 43 | Alias: createReq.Alias, 44 | }, nil 45 | } 46 | 47 | // FromUpdateRequest converts an update request to a domain model 48 | func (c *ExampleConverter) FromUpdateRequest(req any) (*model.Example, error) { 49 | updateReq, ok := req.(*dto.UpdateExampleReq) 50 | if !ok { 51 | return nil, fmt.Errorf("invalid request type: %T", req) 52 | } 53 | 54 | return &model.Example{ 55 | Id: int(updateReq.Id), 56 | Name: updateReq.Name, 57 | Alias: updateReq.Alias, 58 | }, nil 59 | } 60 | -------------------------------------------------------------------------------- /adapter/converter/example_converter_test.go: -------------------------------------------------------------------------------- 1 | package converter 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/stretchr/testify/assert" 8 | 9 | "go-hexagonal/api/dto" 10 | "go-hexagonal/domain/model" 11 | ) 12 | 13 | func TestNewExampleConverter(t *testing.T) { 14 | // Test that NewExampleConverter returns a non-nil converter 15 | converter := NewExampleConverter() 16 | assert.NotNil(t, converter, "Converter should not be nil") 17 | assert.IsType(t, &ExampleConverter{}, converter, "Converter should be of type *ExampleConverter") 18 | } 19 | 20 | func TestExampleConverter_ToExampleResponse(t *testing.T) { 21 | // Create a converter 22 | converter := NewExampleConverter() 23 | 24 | t.Run("Valid Conversion", func(t *testing.T) { 25 | // Create test data 26 | now := time.Now() 27 | example := &model.Example{ 28 | Id: 123, 29 | Name: "Test Example", 30 | Alias: "test", 31 | CreatedAt: now, 32 | UpdatedAt: now, 33 | } 34 | 35 | // Convert to response 36 | resp, err := converter.ToExampleResponse(example) 37 | 38 | // Assertions 39 | assert.NoError(t, err) 40 | assert.NotNil(t, resp) 41 | 42 | // Check type and values 43 | typedResp, ok := resp.(*dto.CreateExampleResp) 44 | assert.True(t, ok, "Response should be of type *dto.CreateExampleResp") 45 | assert.Equal(t, uint(123), typedResp.Id) 46 | assert.Equal(t, "Test Example", typedResp.Name) 47 | assert.Equal(t, "test", typedResp.Alias) 48 | assert.Equal(t, now, typedResp.CreatedAt) 49 | assert.Equal(t, now, typedResp.UpdatedAt) 50 | }) 51 | 52 | t.Run("Nil Example", func(t *testing.T) { 53 | // Try to convert nil 54 | resp, err := converter.ToExampleResponse(nil) 55 | 56 | // Assertions 57 | assert.Error(t, err) 58 | assert.Nil(t, resp) 59 | assert.Contains(t, err.Error(), "example is nil") 60 | }) 61 | } 62 | 63 | func TestExampleConverter_FromCreateRequest(t *testing.T) { 64 | // Create a converter 65 | converter := NewExampleConverter() 66 | 67 | t.Run("Valid Conversion", func(t *testing.T) { 68 | // Create a create request 69 | req := &dto.CreateExampleReq{ 70 | Name: "Test Example", 71 | Alias: "test", 72 | } 73 | 74 | // Convert to domain model 75 | model, err := converter.FromCreateRequest(req) 76 | 77 | // Assertions 78 | assert.NoError(t, err) 79 | assert.NotNil(t, model) 80 | assert.Equal(t, "Test Example", model.Name) 81 | assert.Equal(t, "test", model.Alias) 82 | assert.Zero(t, model.Id, "Id should not be set") 83 | }) 84 | 85 | t.Run("Invalid Request Type", func(t *testing.T) { 86 | // Try to convert an invalid type 87 | model, err := converter.FromCreateRequest("invalid type") 88 | 89 | // Assertions 90 | assert.Error(t, err) 91 | assert.Nil(t, model) 92 | assert.Contains(t, err.Error(), "invalid request type") 93 | }) 94 | } 95 | 96 | func TestExampleConverter_FromUpdateRequest(t *testing.T) { 97 | // Create a converter 98 | converter := NewExampleConverter() 99 | 100 | t.Run("Valid Conversion", func(t *testing.T) { 101 | // Create an update request 102 | req := &dto.UpdateExampleReq{ 103 | Id: 456, 104 | Name: "Updated Example", 105 | Alias: "updated", 106 | } 107 | 108 | // Convert to domain model 109 | model, err := converter.FromUpdateRequest(req) 110 | 111 | // Assertions 112 | assert.NoError(t, err) 113 | assert.NotNil(t, model) 114 | assert.Equal(t, 456, model.Id) 115 | assert.Equal(t, "Updated Example", model.Name) 116 | assert.Equal(t, "updated", model.Alias) 117 | }) 118 | 119 | t.Run("Invalid Request Type", func(t *testing.T) { 120 | // Try to convert an invalid type 121 | model, err := converter.FromUpdateRequest("invalid type") 122 | 123 | // Assertions 124 | assert.Error(t, err) 125 | assert.Nil(t, model) 126 | assert.Contains(t, err.Error(), "invalid request type") 127 | }) 128 | } 129 | -------------------------------------------------------------------------------- /adapter/dependency/grpc/grpc.go: -------------------------------------------------------------------------------- 1 | package grpc 2 | -------------------------------------------------------------------------------- /adapter/dependency/http/http.go: -------------------------------------------------------------------------------- 1 | package http 2 | -------------------------------------------------------------------------------- /adapter/dependency/wire_gen.go: -------------------------------------------------------------------------------- 1 | // Code generated by Wire. DO NOT EDIT. 2 | 3 | //go:generate go run -mod=mod github.com/google/wire/cmd/wire 4 | //go:build !wireinject 5 | // +build !wireinject 6 | 7 | package dependency 8 | 9 | import ( 10 | "context" 11 | 12 | "go-hexagonal/adapter/repository" 13 | "go-hexagonal/adapter/repository/mysql/entity" 14 | "go-hexagonal/config" 15 | "go-hexagonal/domain/event" 16 | "go-hexagonal/domain/repo" 17 | "go-hexagonal/domain/service" 18 | ) 19 | 20 | // ServiceOption defines an option for service initialization 21 | type ServiceOption func(*service.Services, event.EventBus) 22 | 23 | // WithExampleService returns an option to initialize the Example service 24 | func WithExampleService() ServiceOption { 25 | return func(s *service.Services, eventBus event.EventBus) { 26 | if s.ExampleService == nil { 27 | exampleRepo := entity.NewExample() 28 | s.ExampleService = provideExampleService(exampleRepo, eventBus) 29 | } 30 | } 31 | } 32 | 33 | // InitializeServices initializes services based on the provided options 34 | func InitializeServices(ctx context.Context, opts ...ServiceOption) (*service.Services, error) { 35 | // Initialize services container 36 | eventBus := provideEventBus() 37 | services := &service.Services{ 38 | EventBus: eventBus, 39 | } 40 | 41 | // Apply service options 42 | for _, opt := range opts { 43 | opt(services, eventBus) 44 | } 45 | 46 | return services, nil 47 | } 48 | 49 | // RepositoryOption defines an option for repository initialization 50 | type RepositoryOption func(*repository.ClientContainer) 51 | 52 | // WithMySQL returns an option to initialize MySQL 53 | func WithMySQL() RepositoryOption { 54 | return func(c *repository.ClientContainer) { 55 | if c.MySQL == nil { 56 | mysql, err := ProvideMySQL() 57 | if err != nil { 58 | panic("Failed to initialize MySQL: " + err.Error()) 59 | } 60 | c.MySQL = mysql 61 | } 62 | } 63 | } 64 | 65 | // WithRedis returns an option to initialize Redis 66 | func WithRedis() RepositoryOption { 67 | return func(c *repository.ClientContainer) { 68 | if c.Redis == nil { 69 | redis, err := ProvideRedis() 70 | if err != nil { 71 | panic("Failed to initialize Redis: " + err.Error()) 72 | } 73 | c.Redis = redis 74 | } 75 | } 76 | } 77 | 78 | // InitializeRepositories initializes repository clients with the given options 79 | func InitializeRepositories(opts ...RepositoryOption) (*repository.ClientContainer, error) { 80 | container := &repository.ClientContainer{} 81 | for _, opt := range opts { 82 | opt(container) 83 | } 84 | return container, nil 85 | } 86 | 87 | // ProvideMySQL creates and initializes a MySQL client 88 | func ProvideMySQL() (*repository.MySQL, error) { 89 | if config.GlobalConfig.MySQL == nil { 90 | return nil, repository.ErrMissingMySQLConfig 91 | } 92 | 93 | db, err := repository.OpenGormDB() 94 | if err != nil { 95 | return nil, err 96 | } 97 | 98 | return &repository.MySQL{DB: db}, nil 99 | } 100 | 101 | // ProvideRedis creates and initializes a Redis client 102 | func ProvideRedis() (*repository.Redis, error) { 103 | if config.GlobalConfig.Redis == nil { 104 | return nil, repository.ErrMissingRedisConfig 105 | } 106 | 107 | client := repository.NewRedisConn() 108 | return &repository.Redis{DB: client}, nil 109 | } 110 | 111 | // wire.go: 112 | 113 | // provideEventBus creates and configures the event bus 114 | func provideEventBus() *event.InMemoryEventBus { 115 | eventBus := event.NewInMemoryEventBus() 116 | 117 | loggingHandler := event.NewLoggingEventHandler() 118 | exampleHandler := event.NewExampleEventHandler() 119 | eventBus.Subscribe(loggingHandler) 120 | eventBus.Subscribe(exampleHandler) 121 | 122 | return eventBus 123 | } 124 | 125 | // provideExampleService creates and configures the example service 126 | func provideExampleService(repo2 repo.IExampleRepo, eventBus event.EventBus) *service.ExampleService { 127 | // 暂时使用nil代替缓存存储库,未来可实现真正的缓存 128 | var cacheRepo repo.IExampleCacheRepo = nil 129 | exampleService := service.NewExampleService(repo2, cacheRepo) 130 | exampleService.EventBus = eventBus 131 | return exampleService 132 | } 133 | 134 | // Deprecated: Use the new InitializeServices with options pattern instead 135 | func provideServices(exampleService *service.ExampleService, eventBus event.EventBus) *service.Services { 136 | return service.NewServices(exampleService, eventBus) 137 | } 138 | -------------------------------------------------------------------------------- /adapter/job/job.go: -------------------------------------------------------------------------------- 1 | package job 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "sync" 7 | "time" 8 | 9 | "github.com/robfig/cron/v3" 10 | "go.uber.org/zap" 11 | 12 | "go-hexagonal/util/log" 13 | ) 14 | 15 | // Job represents a scheduled job 16 | type Job interface { 17 | // Name returns the job name 18 | Name() string 19 | // Run executes the job 20 | Run(ctx context.Context) error 21 | } 22 | 23 | // Scheduler manages scheduled jobs 24 | type Scheduler struct { 25 | cron *cron.Cron 26 | jobs map[string]Job 27 | jobSpecs map[string]string 28 | mu sync.RWMutex 29 | } 30 | 31 | // DefaultJobTimeout is the default timeout for job execution 32 | const DefaultJobTimeout = 5 * time.Minute 33 | 34 | // NewScheduler creates a new job scheduler 35 | func NewScheduler() *Scheduler { 36 | return &Scheduler{ 37 | cron: cron.New(cron.WithSeconds()), 38 | jobs: make(map[string]Job), 39 | jobSpecs: make(map[string]string), 40 | } 41 | } 42 | 43 | // AddJob adds a job to the scheduler 44 | func (s *Scheduler) AddJob(spec string, job Job) error { 45 | s.mu.Lock() 46 | defer s.mu.Unlock() 47 | 48 | if _, exists := s.jobs[job.Name()]; exists { 49 | return fmt.Errorf("job %s already exists", job.Name()) 50 | } 51 | 52 | _, err := s.cron.AddFunc(spec, func() { 53 | ctx, cancel := context.WithTimeout(context.Background(), DefaultJobTimeout) 54 | defer cancel() 55 | 56 | start := time.Now() 57 | log.Logger.Info("Starting job", 58 | zap.String("job", job.Name()), 59 | zap.String("spec", spec), 60 | ) 61 | 62 | if err := job.Run(ctx); err != nil { 63 | log.Logger.Error("Job failed", 64 | zap.String("job", job.Name()), 65 | zap.Error(err), 66 | zap.Duration("duration", time.Since(start)), 67 | ) 68 | return 69 | } 70 | 71 | log.Logger.Info("Job completed", 72 | zap.String("job", job.Name()), 73 | zap.Duration("duration", time.Since(start)), 74 | ) 75 | }) 76 | 77 | if err != nil { 78 | return fmt.Errorf("failed to add job %s: %w", job.Name(), err) 79 | } 80 | 81 | s.jobs[job.Name()] = job 82 | s.jobSpecs[job.Name()] = spec 83 | return nil 84 | } 85 | 86 | // RemoveJob removes a job from the scheduler 87 | func (s *Scheduler) RemoveJob(name string) error { 88 | s.mu.Lock() 89 | defer s.mu.Unlock() 90 | 91 | if _, exists := s.jobs[name]; !exists { 92 | return fmt.Errorf("job %s not found", name) 93 | } 94 | 95 | delete(s.jobs, name) 96 | delete(s.jobSpecs, name) 97 | return nil 98 | } 99 | 100 | // Start starts the scheduler 101 | func (s *Scheduler) Start() { 102 | s.cron.Start() 103 | log.Logger.Info("Job scheduler started") 104 | } 105 | 106 | // Stop stops the scheduler 107 | func (s *Scheduler) Stop() { 108 | ctx := s.cron.Stop() 109 | <-ctx.Done() 110 | log.Logger.Info("Job scheduler stopped") 111 | } 112 | 113 | // ListJobs returns a list of all registered jobs 114 | func (s *Scheduler) ListJobs() map[string]string { 115 | s.mu.RLock() 116 | defer s.mu.RUnlock() 117 | 118 | jobs := make(map[string]string) 119 | for name, spec := range s.jobSpecs { 120 | jobs[name] = spec 121 | } 122 | return jobs 123 | } 124 | -------------------------------------------------------------------------------- /adapter/repository/DDL.sql: -------------------------------------------------------------------------------- 1 | CREATE DATABASE `go-hexagonal`; 2 | 3 | USE `go-hexagonal`; 4 | 5 | DROP TABLE IF EXISTS `example`; 6 | 7 | CREATE TABLE `example` ( 8 | `id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', 9 | `name` VARCHAR(255) NOT NULL COMMENT '名称', 10 | `alias` VARCHAR(255) DEFAULT NULL COMMENT '别称', 11 | `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', 12 | `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', 13 | `deleted_at` TIMESTAMP NULL DEFAULT NULL COMMENT '删除时间', 14 | PRIMARY KEY (`id`), 15 | KEY `idx_name` (`name`), 16 | KEY `idx_deleted_at` (`deleted_at`) 17 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Hexagonal示例表'; 18 | -------------------------------------------------------------------------------- /adapter/repository/error.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | // RepositoryError represents a repository-specific error 4 | type RepositoryError string 5 | 6 | // Error returns the error message 7 | func (e RepositoryError) Error() string { 8 | return string(e) 9 | } 10 | 11 | // Common repository errors 12 | var ( 13 | // ErrMissingMySQLConfig is returned when MySQL configuration is missing 14 | ErrMissingMySQLConfig = RepositoryError("MySQL configuration is missing") 15 | 16 | // ErrMissingPostgreSQLConfig is returned when PostgreSQL configuration is missing 17 | ErrMissingPostgreSQLConfig = RepositoryError("PostgreSQL configuration is missing") 18 | 19 | // ErrMissingRedisConfig is returned when Redis configuration is missing 20 | ErrMissingRedisConfig = RepositoryError("Redis configuration is missing") 21 | 22 | // ErrInvalidTransaction is returned when attempting to use an invalid transaction 23 | ErrInvalidTransaction = RepositoryError("invalid transaction") 24 | 25 | // ErrInvalidSession is returned when attempting to use an invalid session 26 | ErrInvalidSession = RepositoryError("invalid session") 27 | 28 | // ErrUnsupportedStoreType is returned when using an unsupported store type 29 | ErrUnsupportedStoreType = RepositoryError("unsupported store type") 30 | ) 31 | -------------------------------------------------------------------------------- /adapter/repository/main_test.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "go.uber.org/zap" 8 | 9 | "go-hexagonal/util/log" 10 | ) 11 | 12 | func TestMain(m *testing.M) { 13 | // 初始化日志配置 14 | initTestLogger() 15 | 16 | // 运行测试 17 | exitCode := m.Run() 18 | 19 | // 退出 20 | os.Exit(exitCode) 21 | } 22 | 23 | // initTestLogger 初始化测试环境的日志配置 24 | func initTestLogger() { 25 | // 使用最简单的控制台日志配置 26 | logger, _ := zap.NewDevelopment() 27 | zap.ReplaceGlobals(logger) 28 | 29 | // 初始化全局日志变量 30 | log.Logger = logger 31 | log.SugaredLogger = logger.Sugar() 32 | } 33 | -------------------------------------------------------------------------------- /adapter/repository/mysql/client.go: -------------------------------------------------------------------------------- 1 | package mysql 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "os" 8 | "time" 9 | 10 | "github.com/spf13/cast" 11 | "gorm.io/driver/mysql" 12 | "gorm.io/gorm" 13 | "gorm.io/gorm/logger" 14 | "gorm.io/gorm/schema" 15 | 16 | "go-hexagonal/adapter/repository" 17 | ) 18 | 19 | // MySQLClient represents a MySQL database client using GORM 20 | type MySQLClient struct { 21 | DB *gorm.DB 22 | } 23 | 24 | // NewMySQLClient creates a new MySQL client 25 | func NewMySQLClient(dsn string) (*MySQLClient, error) { 26 | if dsn == "" { 27 | return nil, repository.ErrMissingMySQLConfig 28 | } 29 | 30 | db, err := openMySQLDB(dsn) 31 | if err != nil { 32 | return nil, err 33 | } 34 | 35 | return &MySQLClient{DB: db}, nil 36 | } 37 | 38 | // GetDB returns the GORM database instance with context 39 | func (c *MySQLClient) GetDB(ctx context.Context) *gorm.DB { 40 | return c.DB.WithContext(ctx) 41 | } 42 | 43 | // SetDB sets the GORM database instance 44 | func (c *MySQLClient) SetDB(db *gorm.DB) { 45 | c.DB = db 46 | } 47 | 48 | // Close closes the MySQL database connection 49 | func (c *MySQLClient) Close(ctx context.Context) error { 50 | sqlDB, err := c.GetDB(ctx).DB() 51 | if err != nil { 52 | return fmt.Errorf("failed to get MySQL DB: %w", err) 53 | } 54 | 55 | if sqlDB != nil { 56 | if err := sqlDB.Close(); err != nil { 57 | return fmt.Errorf("failed to close MySQL connection: %w", err) 58 | } 59 | } 60 | 61 | return nil 62 | } 63 | 64 | // openMySQLDB creates and opens a new GORM database connection 65 | func openMySQLDB(dsn string) (*gorm.DB, error) { 66 | // Create MySQL dialect 67 | dialect := mysql.Open(dsn) 68 | 69 | // Configure GORM logger 70 | gormLogger := logger.New( 71 | log.New(os.Stdout, "\r\n", log.LstdFlags), 72 | logger.Config{ 73 | SlowThreshold: time.Second, // Slow SQL threshold 74 | LogLevel: logger.Info, // Log level 75 | IgnoreRecordNotFoundError: false, // Do not ignore ErrRecordNotFound 76 | Colorful: true, // Enable colorful output 77 | }, 78 | ) 79 | 80 | // GORM configuration 81 | gormConfig := &gorm.Config{ 82 | NamingStrategy: schema.NamingStrategy{SingularTable: true}, 83 | Logger: gormLogger, 84 | } 85 | 86 | // Open database connection 87 | db, err := gorm.Open(dialect, gormConfig) 88 | if err != nil { 89 | return nil, fmt.Errorf("failed to open MySQL connection: %w", err) 90 | } 91 | 92 | // Configure connection pool 93 | sqlDB, err := db.DB() 94 | if err != nil { 95 | return nil, fmt.Errorf("failed to get SQL DB: %w", err) 96 | } 97 | 98 | // Default connection pool settings 99 | // In a real application, these should be configured from config files 100 | sqlDB.SetMaxIdleConns(10) 101 | sqlDB.SetMaxOpenConns(100) 102 | sqlDB.SetConnMaxLifetime(time.Hour) 103 | sqlDB.SetConnMaxIdleTime(30 * time.Minute) 104 | 105 | return db, nil 106 | } 107 | 108 | // ConfigureConnectionPool configures the MySQL connection pool 109 | func ConfigureConnectionPool(db *gorm.DB, maxIdleConns, maxOpenConns int, maxLifetime, maxIdleTime time.Duration) error { 110 | sqlDB, err := db.DB() 111 | if err != nil { 112 | return fmt.Errorf("failed to get SQL DB: %w", err) 113 | } 114 | 115 | sqlDB.SetMaxIdleConns(maxIdleConns) 116 | sqlDB.SetMaxOpenConns(maxOpenConns) 117 | sqlDB.SetConnMaxLifetime(cast.ToDuration(maxLifetime)) 118 | sqlDB.SetConnMaxIdleTime(cast.ToDuration(maxIdleTime)) 119 | 120 | return nil 121 | } 122 | -------------------------------------------------------------------------------- /adapter/repository/mysql/entity/example.go: -------------------------------------------------------------------------------- 1 | package entity 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "go-hexagonal/domain/model" 8 | "go-hexagonal/domain/repo" 9 | ) 10 | 11 | // Example represents the MySQL implementation of IExampleRepo 12 | type Example struct { 13 | // Database connection or any dependencies could be added here 14 | } 15 | 16 | // NewExample creates a new Example repository 17 | func NewExample() *Example { 18 | return &Example{} 19 | } 20 | 21 | // Create implements IExampleRepo.Create 22 | func (e *Example) Create(ctx context.Context, tr repo.Transaction, example *model.Example) (*model.Example, error) { 23 | // Implement actual database logic for creation 24 | return example, nil 25 | } 26 | 27 | // GetByID implements IExampleRepo.GetByID 28 | func (e *Example) GetByID(ctx context.Context, tr repo.Transaction, id int) (*model.Example, error) { 29 | // Implement actual database logic for fetching 30 | return &model.Example{ 31 | Id: id, 32 | Name: "Example from MySQL", 33 | Alias: "MySQL Demo", 34 | CreatedAt: time.Now(), 35 | UpdatedAt: time.Now(), 36 | }, nil 37 | } 38 | 39 | // Update implements IExampleRepo.Update 40 | func (e *Example) Update(ctx context.Context, tr repo.Transaction, example *model.Example) error { 41 | // Implement actual database logic for updating 42 | return nil 43 | } 44 | 45 | // Delete implements IExampleRepo.Delete 46 | func (e *Example) Delete(ctx context.Context, tr repo.Transaction, id int) error { 47 | // Implement actual database logic for deletion 48 | return nil 49 | } 50 | 51 | // FindByName implements IExampleRepo.FindByName 52 | func (e *Example) FindByName(ctx context.Context, tr repo.Transaction, name string) (*model.Example, error) { 53 | // Implement actual database logic for finding by name 54 | return &model.Example{ 55 | Id: 1, 56 | Name: name, 57 | Alias: "MySQL Mock", 58 | CreatedAt: time.Now(), 59 | UpdatedAt: time.Now(), 60 | }, nil 61 | } 62 | 63 | // WithTransaction implements IExampleRepo.WithTransaction 64 | func (e *Example) WithTransaction(ctx context.Context, tx repo.Transaction) repo.IExampleRepo { 65 | // Return the same repository for now, as it's a mock 66 | // In a real implementation, this would create a new repository that uses the transaction 67 | return e 68 | } 69 | -------------------------------------------------------------------------------- /adapter/repository/mysql/example_repo.go: -------------------------------------------------------------------------------- 1 | package mysql 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "time" 7 | 8 | "gorm.io/gorm" 9 | 10 | "go-hexagonal/adapter/repository" 11 | "go-hexagonal/domain/model" 12 | "go-hexagonal/domain/repo" 13 | ) 14 | 15 | // ExampleRepo implements the example repository for MySQL 16 | type ExampleRepo struct { 17 | client *MySQLClient 18 | } 19 | 20 | // NewExampleRepo creates a new MySQL example repository 21 | func NewExampleRepo(client *MySQLClient) repo.IExampleRepo { 22 | return &ExampleRepo{ 23 | client: client, 24 | } 25 | } 26 | 27 | // Create creates a new example in the database 28 | func (r *ExampleRepo) Create(ctx context.Context, tr repo.Transaction, example *model.Example) (*model.Example, error) { 29 | // Set timestamps 30 | now := time.Now() 31 | example.CreatedAt = now 32 | example.UpdatedAt = now 33 | 34 | // Get DB connection (from transaction or direct client) 35 | db := r.getDB(ctx, tr) 36 | 37 | // Create record 38 | if err := db.Create(example).Error; err != nil { 39 | return nil, err 40 | } 41 | 42 | return example, nil 43 | } 44 | 45 | // Update updates an existing example 46 | func (r *ExampleRepo) Update(ctx context.Context, tr repo.Transaction, example *model.Example) error { 47 | // Set update timestamp 48 | example.UpdatedAt = time.Now() 49 | 50 | // Get DB connection (from transaction or direct client) 51 | db := r.getDB(ctx, tr) 52 | 53 | // Update record 54 | result := db.Model(&model.Example{}).Where("id = ?", example.Id).Updates(example) 55 | if result.Error != nil { 56 | return result.Error 57 | } 58 | 59 | // Check if record exists 60 | if result.RowsAffected == 0 { 61 | return repo.ErrNotFound 62 | } 63 | 64 | return nil 65 | } 66 | 67 | // Delete deletes an example by ID 68 | func (r *ExampleRepo) Delete(ctx context.Context, tr repo.Transaction, id int) error { 69 | // Get DB connection (from transaction or direct client) 70 | db := r.getDB(ctx, tr) 71 | 72 | // Delete record 73 | result := db.Delete(&model.Example{}, id) 74 | if result.Error != nil { 75 | return result.Error 76 | } 77 | 78 | // Check if record exists 79 | if result.RowsAffected == 0 { 80 | return repo.ErrNotFound 81 | } 82 | 83 | return nil 84 | } 85 | 86 | // GetByID retrieves an example by ID 87 | func (r *ExampleRepo) GetByID(ctx context.Context, tr repo.Transaction, id int) (*model.Example, error) { 88 | // Get DB connection (from transaction or direct client) 89 | db := r.getDB(ctx, tr) 90 | 91 | // Find record 92 | var example model.Example 93 | if err := db.Where("id = ?", id).First(&example).Error; err != nil { 94 | if errors.Is(err, gorm.ErrRecordNotFound) { 95 | return nil, repo.ErrNotFound 96 | } 97 | return nil, err 98 | } 99 | 100 | return &example, nil 101 | } 102 | 103 | // FindByName retrieves an example by name 104 | func (r *ExampleRepo) FindByName(ctx context.Context, tr repo.Transaction, name string) (*model.Example, error) { 105 | // Get DB connection (from transaction or direct client) 106 | db := r.getDB(ctx, tr) 107 | 108 | // Find record 109 | var example model.Example 110 | if err := db.Where("name = ?", name).First(&example).Error; err != nil { 111 | if errors.Is(err, gorm.ErrRecordNotFound) { 112 | return nil, repo.ErrNotFound 113 | } 114 | return nil, err 115 | } 116 | 117 | return &example, nil 118 | } 119 | 120 | // getDB returns the appropriate database connection based on transaction 121 | func (r *ExampleRepo) getDB(ctx context.Context, tr repo.Transaction) *gorm.DB { 122 | if tr != nil { 123 | // Use transaction context 124 | txCtx := tr.Context() 125 | // Check if we can get session from transaction implementation 126 | if repo, ok := tr.(*repository.Transaction); ok && repo.Session != nil { 127 | return repo.Session.WithContext(txCtx) 128 | } 129 | } 130 | return r.client.GetDB(ctx) 131 | } 132 | -------------------------------------------------------------------------------- /adapter/repository/mysql/testcontainer_test.go: -------------------------------------------------------------------------------- 1 | package mysql 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestSetupMySQLContainer(t *testing.T) { 10 | // Skip this test in CI environments or when running quick tests 11 | if testing.Short() { 12 | t.Skip("Skipping MySQL container test in short mode") 13 | } 14 | 15 | // Create MySQL container 16 | config := SetupMySQLContainer(t) 17 | 18 | // Validate configuration 19 | assert.NotEmpty(t, config.Host, "Host should not be empty") 20 | assert.NotZero(t, config.Port, "Port should be greater than 0") 21 | assert.Equal(t, "root", config.User) 22 | assert.Equal(t, "mysqlroot", config.Password) 23 | assert.Equal(t, "go_hexagonal", config.Database) 24 | assert.Equal(t, "utf8mb4", config.CharSet) 25 | assert.Equal(t, true, config.ParseTime) 26 | assert.Equal(t, "UTC", config.TimeZone) 27 | 28 | // Validate additional config fields 29 | assert.Equal(t, 10, config.MaxIdleConns) 30 | assert.Equal(t, 100, config.MaxOpenConns) 31 | assert.Equal(t, "1h", config.MaxLifeTime) 32 | assert.Equal(t, "30m", config.MaxIdleTime) 33 | 34 | // Get database connection 35 | db := GetTestDB(t, config) 36 | 37 | // Verify connection by executing a simple query 38 | var result int 39 | err := db.DB.Raw("SELECT 1").Scan(&result).Error 40 | assert.NoError(t, err, "Should be able to execute a simple query") 41 | assert.Equal(t, 1, result, "Query result should be 1") 42 | 43 | // Test creating a table 44 | err = db.DB.Exec(` 45 | CREATE TABLE IF NOT EXISTS test_table ( 46 | id INT AUTO_INCREMENT PRIMARY KEY, 47 | name VARCHAR(255) NOT NULL 48 | ) 49 | `).Error 50 | assert.NoError(t, err, "Should be able to create a table") 51 | 52 | // Test MockMySQLData function 53 | mockSQLs := []string{ 54 | "INSERT INTO test_table (name) VALUES ('test1')", 55 | "INSERT INTO test_table (name) VALUES ('test2')", 56 | } 57 | 58 | MockMySQLData(t, db, mockSQLs) 59 | 60 | // Verify data was inserted 61 | var count int64 62 | err = db.DB.Table("test_table").Count(&count).Error 63 | assert.NoError(t, err, "Should be able to count rows") 64 | assert.Equal(t, int64(2), count, "There should be 2 rows in the table") 65 | 66 | // Verify specific data 67 | type TestRow struct { 68 | ID int 69 | Name string 70 | } 71 | 72 | var rows []TestRow 73 | err = db.DB.Table("test_table").Find(&rows).Error 74 | assert.NoError(t, err, "Should be able to query rows") 75 | assert.Len(t, rows, 2, "There should be 2 rows") 76 | assert.Equal(t, "test1", rows[0].Name, "First row should have name 'test1'") 77 | assert.Equal(t, "test2", rows[1].Name, "Second row should have name 'test2'") 78 | } 79 | -------------------------------------------------------------------------------- /adapter/repository/postgre/client.go: -------------------------------------------------------------------------------- 1 | package postgre 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "os" 8 | "time" 9 | 10 | "github.com/jackc/pgx/v5/pgxpool" 11 | "gorm.io/driver/postgres" 12 | "gorm.io/gorm" 13 | "gorm.io/gorm/logger" 14 | "gorm.io/gorm/schema" 15 | 16 | "go-hexagonal/adapter/repository" 17 | "go-hexagonal/config" 18 | ) 19 | 20 | // PostgreSQLClient represents a PostgreSQL database client using GORM 21 | type PostgreSQLClient struct { 22 | DB *gorm.DB 23 | } 24 | 25 | // NewPostgreSQLClient creates a new PostgreSQL client 26 | func NewPostgreSQLClient(dsn string) (*PostgreSQLClient, error) { 27 | if dsn == "" { 28 | return nil, repository.ErrMissingPostgreSQLConfig 29 | } 30 | 31 | db, err := openPostgreSQLDB(dsn) 32 | if err != nil { 33 | return nil, err 34 | } 35 | 36 | return &PostgreSQLClient{DB: db}, nil 37 | } 38 | 39 | // GetDB returns the GORM database instance with context 40 | func (c *PostgreSQLClient) GetDB(ctx context.Context) *gorm.DB { 41 | return c.DB.WithContext(ctx) 42 | } 43 | 44 | // SetDB sets the GORM database instance 45 | func (c *PostgreSQLClient) SetDB(db *gorm.DB) { 46 | c.DB = db 47 | } 48 | 49 | // Close closes the PostgreSQL database connection 50 | func (c *PostgreSQLClient) Close(ctx context.Context) error { 51 | sqlDB, err := c.GetDB(ctx).DB() 52 | if err != nil { 53 | return fmt.Errorf("failed to get PostgreSQL DB: %w", err) 54 | } 55 | 56 | if sqlDB != nil { 57 | if err := sqlDB.Close(); err != nil { 58 | return fmt.Errorf("failed to close PostgreSQL connection: %w", err) 59 | } 60 | } 61 | 62 | return nil 63 | } 64 | 65 | // openPostgreSQLDB creates and opens a new GORM database connection for PostgreSQL 66 | func openPostgreSQLDB(dsn string) (*gorm.DB, error) { 67 | // Create PostgreSQL dialect 68 | dialect := postgres.Open(dsn) 69 | 70 | // Configure GORM logger 71 | gormLogger := logger.New( 72 | log.New(os.Stdout, "\r\n", log.LstdFlags), 73 | logger.Config{ 74 | SlowThreshold: time.Second, // Slow SQL threshold 75 | LogLevel: logger.Info, // Log level 76 | IgnoreRecordNotFoundError: false, // Do not ignore ErrRecordNotFound 77 | Colorful: true, // Enable colorful output 78 | }, 79 | ) 80 | 81 | // GORM configuration 82 | gormConfig := &gorm.Config{ 83 | NamingStrategy: schema.NamingStrategy{SingularTable: true}, 84 | Logger: gormLogger, 85 | } 86 | 87 | // Open database connection 88 | db, err := gorm.Open(dialect, gormConfig) 89 | if err != nil { 90 | return nil, fmt.Errorf("failed to open PostgreSQL connection: %w", err) 91 | } 92 | 93 | // Configure connection pool 94 | sqlDB, err := db.DB() 95 | if err != nil { 96 | return nil, fmt.Errorf("failed to get SQL DB: %w", err) 97 | } 98 | 99 | // Default connection pool settings 100 | // In a real application, these should be configured from config files 101 | sqlDB.SetMaxIdleConns(10) 102 | sqlDB.SetMaxOpenConns(100) 103 | sqlDB.SetConnMaxLifetime(time.Hour) 104 | sqlDB.SetConnMaxIdleTime(30 * time.Minute) 105 | 106 | return db, nil 107 | } 108 | 109 | // ConfigureConnectionPool configures the PostgreSQL connection pool 110 | func ConfigureConnectionPool(db *gorm.DB, maxIdleConns, maxOpenConns int, maxLifetime, maxIdleTime time.Duration) error { 111 | sqlDB, err := db.DB() 112 | if err != nil { 113 | return fmt.Errorf("failed to get SQL DB: %w", err) 114 | } 115 | 116 | sqlDB.SetMaxIdleConns(maxIdleConns) 117 | sqlDB.SetMaxOpenConns(maxOpenConns) 118 | sqlDB.SetConnMaxLifetime(maxLifetime) 119 | sqlDB.SetConnMaxIdleTime(maxIdleTime) 120 | 121 | return nil 122 | } 123 | 124 | // NewConnPool creates a new PostgreSQL connection pool using pgx 125 | func NewConnPool(pgConfig *config.PostgreSQLConfig) (*pgxpool.Pool, error) { 126 | connString := fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=%s", 127 | pgConfig.User, 128 | pgConfig.Password, 129 | pgConfig.Host, 130 | pgConfig.Port, 131 | pgConfig.Database, 132 | pgConfig.SSLMode, 133 | ) 134 | 135 | poolConfig, err := pgxpool.ParseConfig(connString) 136 | if err != nil { 137 | return nil, fmt.Errorf("failed to parse connection string: %w", err) 138 | } 139 | 140 | // Configure connection pool 141 | if pgConfig.MaxConnections > 0 { 142 | poolConfig.MaxConns = int32(pgConfig.MaxConnections) 143 | } 144 | if pgConfig.MinConnections > 0 { 145 | poolConfig.MinConns = int32(pgConfig.MinConnections) 146 | } 147 | if pgConfig.MaxConnLifetime > 0 { 148 | poolConfig.MaxConnLifetime = time.Duration(pgConfig.MaxConnLifetime) * time.Second 149 | } 150 | if pgConfig.IdleTimeout > 0 { 151 | poolConfig.MaxConnIdleTime = time.Duration(pgConfig.IdleTimeout) * time.Second 152 | } 153 | if pgConfig.ConnectTimeout > 0 { 154 | poolConfig.ConnConfig.ConnectTimeout = time.Duration(pgConfig.ConnectTimeout) * time.Second 155 | } 156 | 157 | // Create connection pool 158 | pool, err := pgxpool.NewWithConfig(context.Background(), poolConfig) 159 | if err != nil { 160 | return nil, fmt.Errorf("failed to create connection pool: %w", err) 161 | } 162 | 163 | return pool, nil 164 | } 165 | -------------------------------------------------------------------------------- /adapter/repository/postgre/example_repo.go: -------------------------------------------------------------------------------- 1 | package postgre 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "time" 7 | 8 | "gorm.io/gorm" 9 | 10 | "go-hexagonal/adapter/repository" 11 | "go-hexagonal/domain/model" 12 | "go-hexagonal/domain/repo" 13 | ) 14 | 15 | // ExampleRepo implements the example repository for PostgreSQL 16 | type ExampleRepo struct { 17 | client *PostgreSQLClient 18 | } 19 | 20 | // NewExampleRepo creates a new PostgreSQL example repository 21 | func NewExampleRepo(client *PostgreSQLClient) repo.IExampleRepo { 22 | return &ExampleRepo{ 23 | client: client, 24 | } 25 | } 26 | 27 | // Create creates a new example in the database 28 | func (r *ExampleRepo) Create(ctx context.Context, tr repo.Transaction, example *model.Example) (*model.Example, error) { 29 | // Set timestamps 30 | now := time.Now() 31 | example.CreatedAt = now 32 | example.UpdatedAt = now 33 | 34 | // Get DB connection (from transaction or direct client) 35 | db := r.getDB(ctx, tr) 36 | 37 | // Create record 38 | if err := db.Create(example).Error; err != nil { 39 | return nil, err 40 | } 41 | 42 | return example, nil 43 | } 44 | 45 | // Update updates an existing example 46 | func (r *ExampleRepo) Update(ctx context.Context, tr repo.Transaction, example *model.Example) error { 47 | // Set update timestamp 48 | example.UpdatedAt = time.Now() 49 | 50 | // Get DB connection (from transaction or direct client) 51 | db := r.getDB(ctx, tr) 52 | 53 | // Update record - note the use of updates map to handle zero values properly in PostgreSQL 54 | updates := map[string]interface{}{ 55 | "name": example.Name, 56 | "alias": example.Alias, 57 | "updated_at": example.UpdatedAt, 58 | } 59 | 60 | result := db.Model(&model.Example{}).Where("id = ?", example.Id).Updates(updates) 61 | if result.Error != nil { 62 | return result.Error 63 | } 64 | 65 | // Check if record exists 66 | if result.RowsAffected == 0 { 67 | return repo.ErrNotFound 68 | } 69 | 70 | return nil 71 | } 72 | 73 | // Delete deletes an example by ID 74 | func (r *ExampleRepo) Delete(ctx context.Context, tr repo.Transaction, id int) error { 75 | // Get DB connection (from transaction or direct client) 76 | db := r.getDB(ctx, tr) 77 | 78 | // Delete record 79 | result := db.Delete(&model.Example{}, id) 80 | if result.Error != nil { 81 | return result.Error 82 | } 83 | 84 | // Check if record exists 85 | if result.RowsAffected == 0 { 86 | return repo.ErrNotFound 87 | } 88 | 89 | return nil 90 | } 91 | 92 | // GetByID retrieves an example by ID 93 | func (r *ExampleRepo) GetByID(ctx context.Context, tr repo.Transaction, id int) (*model.Example, error) { 94 | // Get DB connection (from transaction or direct client) 95 | db := r.getDB(ctx, tr) 96 | 97 | // Find record 98 | var example model.Example 99 | if err := db.Where("id = ?", id).First(&example).Error; err != nil { 100 | if errors.Is(err, gorm.ErrRecordNotFound) { 101 | return nil, repo.ErrNotFound 102 | } 103 | return nil, err 104 | } 105 | 106 | return &example, nil 107 | } 108 | 109 | // FindByName retrieves an example by name 110 | func (r *ExampleRepo) FindByName(ctx context.Context, tr repo.Transaction, name string) (*model.Example, error) { 111 | // Get DB connection (from transaction or direct client) 112 | db := r.getDB(ctx, tr) 113 | 114 | // Find record 115 | var example model.Example 116 | if err := db.Where("name = ?", name).First(&example).Error; err != nil { 117 | if errors.Is(err, gorm.ErrRecordNotFound) { 118 | return nil, repo.ErrNotFound 119 | } 120 | return nil, err 121 | } 122 | 123 | return &example, nil 124 | } 125 | 126 | // getDB returns the appropriate database connection based on transaction 127 | func (r *ExampleRepo) getDB(ctx context.Context, tr repo.Transaction) *gorm.DB { 128 | if tr != nil { 129 | // Use transaction context 130 | txCtx := tr.Context() 131 | // Check if we can get session from transaction implementation 132 | if repo, ok := tr.(*repository.Transaction); ok && repo.Session != nil { 133 | return repo.Session.WithContext(txCtx) 134 | } 135 | } 136 | return r.client.GetDB(ctx) 137 | } 138 | -------------------------------------------------------------------------------- /adapter/repository/postgre/testcontainer_test.go: -------------------------------------------------------------------------------- 1 | package postgre 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestSetupPostgreSQLContainer(t *testing.T) { 10 | // Skip this test in CI environments or when running quick tests 11 | if testing.Short() { 12 | t.Skip("Skipping PostgreSQL container test in short mode") 13 | } 14 | 15 | // Create PostgreSQL container 16 | config := SetupPostgreSQLContainer(t) 17 | 18 | // Validate configuration 19 | assert.NotEmpty(t, config.Host, "Host should not be empty") 20 | assert.NotZero(t, config.Port, "Port should be greater than 0") 21 | assert.Equal(t, "postgres", config.User) 22 | assert.Equal(t, "123456", config.Password) 23 | assert.Equal(t, "postgres", config.Database) 24 | assert.Equal(t, "disable", config.SSLMode) 25 | assert.Equal(t, "UTC", config.TimeZone) 26 | 27 | // Validate additional config fields 28 | assert.Equal(t, int32(100), config.MaxConnections) 29 | assert.Equal(t, int32(10), config.MinConnections) 30 | assert.Equal(t, 3600, config.MaxConnLifetime) 31 | assert.Equal(t, 300, config.IdleTimeout) 32 | assert.Equal(t, 10, config.ConnectTimeout) 33 | assert.Empty(t, config.Options) 34 | 35 | // Get database connection 36 | db := GetTestDB(t, config) 37 | 38 | // Verify connection by executing a simple query 39 | var result int 40 | err := db.DB.Raw("SELECT 1").Scan(&result).Error 41 | assert.NoError(t, err, "Should be able to execute a simple query") 42 | assert.Equal(t, 1, result, "Query result should be 1") 43 | 44 | // Test creating a table 45 | err = db.DB.Exec(` 46 | CREATE TABLE IF NOT EXISTS test_table ( 47 | id SERIAL PRIMARY KEY, 48 | name VARCHAR(255) NOT NULL 49 | ) 50 | `).Error 51 | assert.NoError(t, err, "Should be able to create a table") 52 | 53 | // Test MockPostgreSQLData function 54 | mockSQLs := []string{ 55 | "INSERT INTO test_table (name) VALUES ('test1')", 56 | "INSERT INTO test_table (name) VALUES ('test2')", 57 | } 58 | 59 | // Use the MockPostgreSQLData function 60 | MockPostgreSQLData(t, db.DB, mockSQLs) 61 | 62 | // Verify data was inserted 63 | var count int64 64 | err = db.DB.Table("test_table").Count(&count).Error 65 | assert.NoError(t, err, "Should be able to count rows") 66 | assert.Equal(t, int64(2), count, "There should be 2 rows in the table") 67 | 68 | // Verify specific data 69 | type TestRow struct { 70 | ID int 71 | Name string 72 | } 73 | 74 | var rows []TestRow 75 | err = db.DB.Table("test_table").Find(&rows).Error 76 | assert.NoError(t, err, "Should be able to query rows") 77 | assert.Len(t, rows, 2, "There should be 2 rows") 78 | assert.Equal(t, "test1", rows[0].Name, "First row should have name 'test1'") 79 | assert.Equal(t, "test2", rows[1].Name, "Second row should have name 'test2'") 80 | } 81 | -------------------------------------------------------------------------------- /adapter/repository/redis/client.go: -------------------------------------------------------------------------------- 1 | package redis 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/go-redis/redis/v8" 9 | 10 | "go-hexagonal/config" 11 | "go-hexagonal/util/log" 12 | ) 13 | 14 | // ClientOptions holds Redis client configuration options 15 | type ClientOptions struct { 16 | // Address is the Redis server address 17 | Address string 18 | // Password is the Redis server password 19 | Password string 20 | // DB is the Redis database index 21 | DB int 22 | // PoolSize is the maximum number of socket connections 23 | PoolSize int 24 | // MinIdleConns is the minimum number of idle connections 25 | MinIdleConns int 26 | // DialTimeout is the timeout for establishing new connections 27 | DialTimeout time.Duration 28 | // ReadTimeout is the timeout for socket reads 29 | ReadTimeout time.Duration 30 | // WriteTimeout is the timeout for socket writes 31 | WriteTimeout time.Duration 32 | // PoolTimeout is the timeout for getting a connection from the pool 33 | PoolTimeout time.Duration 34 | // IdleTimeout is the timeout for idle connections 35 | IdleTimeout time.Duration 36 | // MaxRetries is the maximum number of retries before giving up 37 | MaxRetries int 38 | // MinRetryBackoff is the minimum backoff between retries 39 | MinRetryBackoff time.Duration 40 | // MaxRetryBackoff is the maximum backoff between retries 41 | MaxRetryBackoff time.Duration 42 | } 43 | 44 | // DefaultClientOptions returns the default Redis client options 45 | func DefaultClientOptions() *ClientOptions { 46 | return &ClientOptions{ 47 | Address: "localhost:6379", 48 | Password: "", 49 | DB: 0, 50 | PoolSize: 10, 51 | MinIdleConns: 5, 52 | DialTimeout: 5 * time.Second, 53 | ReadTimeout: 3 * time.Second, 54 | WriteTimeout: 3 * time.Second, 55 | PoolTimeout: 4 * time.Second, 56 | IdleTimeout: 5 * time.Minute, 57 | MaxRetries: 3, 58 | MinRetryBackoff: 8 * time.Millisecond, 59 | MaxRetryBackoff: 512 * time.Millisecond, 60 | } 61 | } 62 | 63 | // ClientOptionsFromConfig creates client options from application config 64 | func ClientOptionsFromConfig(cfg *config.RedisConfig) *ClientOptions { 65 | addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port) 66 | opts := DefaultClientOptions() 67 | opts.Address = addr 68 | opts.Password = cfg.Password 69 | opts.DB = cfg.DB 70 | 71 | if cfg.PoolSize > 0 { 72 | opts.PoolSize = cfg.PoolSize 73 | } 74 | 75 | if cfg.MinIdleConns > 0 { 76 | opts.MinIdleConns = cfg.MinIdleConns 77 | } 78 | 79 | if cfg.IdleTimeout > 0 { 80 | opts.IdleTimeout = time.Duration(cfg.IdleTimeout) * time.Second 81 | } 82 | 83 | return opts 84 | } 85 | 86 | // RedisClient wraps a Redis client with additional functionality 87 | type RedisClient struct { 88 | Client *redis.Client 89 | opts *ClientOptions 90 | } 91 | 92 | // NewClient creates a new Redis client with the given options 93 | func NewClient(opts *ClientOptions) (*RedisClient, error) { 94 | if opts == nil { 95 | opts = DefaultClientOptions() 96 | } 97 | 98 | redisOpts := &redis.Options{ 99 | Addr: opts.Address, 100 | Password: opts.Password, 101 | DB: opts.DB, 102 | PoolSize: opts.PoolSize, 103 | MinIdleConns: opts.MinIdleConns, 104 | DialTimeout: opts.DialTimeout, 105 | ReadTimeout: opts.ReadTimeout, 106 | WriteTimeout: opts.WriteTimeout, 107 | PoolTimeout: opts.PoolTimeout, 108 | IdleTimeout: opts.IdleTimeout, 109 | MaxRetries: opts.MaxRetries, 110 | MinRetryBackoff: opts.MinRetryBackoff, 111 | MaxRetryBackoff: opts.MaxRetryBackoff, 112 | } 113 | 114 | client := redis.NewClient(redisOpts) 115 | redisClient := &RedisClient{ 116 | Client: client, 117 | opts: opts, 118 | } 119 | 120 | // Verify connection on creation 121 | if err := redisClient.HealthCheck(context.Background()); err != nil { 122 | return nil, fmt.Errorf("failed to connect to Redis: %w", err) 123 | } 124 | 125 | return redisClient, nil 126 | } 127 | 128 | // HealthCheck performs a ping to verify the Redis connection is working 129 | func (c *RedisClient) HealthCheck(ctx context.Context) error { 130 | timeoutCtx, cancel := context.WithTimeout(ctx, c.opts.DialTimeout) 131 | defer cancel() 132 | 133 | status := c.Client.Ping(timeoutCtx) 134 | if status.Err() != nil { 135 | return fmt.Errorf("Redis health check failed: %w", status.Err()) 136 | } 137 | return nil 138 | } 139 | 140 | // Close closes the Redis connection 141 | func (c *RedisClient) Close() error { 142 | log.Logger.Info("Closing Redis connection") 143 | return c.Client.Close() 144 | } 145 | 146 | // Stats returns the Redis client connection stats 147 | func (c *RedisClient) Stats() *redis.PoolStats { 148 | return c.Client.PoolStats() 149 | } 150 | 151 | // WithTimeout returns a new context with timeout 152 | func (c *RedisClient) WithTimeout(ctx context.Context, timeout time.Duration) (context.Context, context.CancelFunc) { 153 | return context.WithTimeout(ctx, timeout) 154 | } 155 | 156 | // NewClientFromConfig creates a new Redis client from application config 157 | func NewClientFromConfig(cfg *config.RedisConfig) (*RedisClient, error) { 158 | opts := ClientOptionsFromConfig(cfg) 159 | return NewClient(opts) 160 | } 161 | -------------------------------------------------------------------------------- /adapter/repository/redis/example_cache_test.go: -------------------------------------------------------------------------------- 1 | package redis 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/go-redis/redis/v8" 8 | "github.com/go-redis/redismock/v8" 9 | "github.com/stretchr/testify/assert" 10 | 11 | "go-hexagonal/domain/model" 12 | "go-hexagonal/domain/repo" 13 | ) 14 | 15 | var testCtx = context.Background() 16 | 17 | // ExampleCache is a simplified implementation for testing 18 | type ExampleCache struct { 19 | client redis.Cmdable 20 | } 21 | 22 | // NewExampleCache creates a new ExampleCache for testing 23 | func NewExampleCache(client redis.Cmdable) repo.IExampleCacheRepo { 24 | return &ExampleCache{ 25 | client: client, 26 | } 27 | } 28 | 29 | // HealthCheck implements the health check functionality 30 | func (c *ExampleCache) HealthCheck(ctx context.Context) error { 31 | return c.client.Ping(ctx).Err() 32 | } 33 | 34 | // Required interface methods with stub implementations for testing 35 | func (c *ExampleCache) GetByID(ctx context.Context, id int) (*model.Example, error) { 36 | return &model.Example{}, nil 37 | } 38 | 39 | func (c *ExampleCache) GetByName(ctx context.Context, name string) (*model.Example, error) { 40 | return &model.Example{}, nil 41 | } 42 | 43 | func (c *ExampleCache) Set(ctx context.Context, example *model.Example) error { 44 | return nil 45 | } 46 | 47 | func (c *ExampleCache) Delete(ctx context.Context, id int) error { 48 | return nil 49 | } 50 | 51 | func (c *ExampleCache) Invalidate(ctx context.Context) error { 52 | return nil 53 | } 54 | 55 | func TestExampleCache_HealthCheck(t *testing.T) { 56 | // Create Redis client and mock 57 | db, mock := redismock.NewClientMock() 58 | mock.ExpectPing().SetVal("PONG") 59 | 60 | // Create Redis cache instance 61 | cache := NewExampleCache(db) 62 | 63 | // Execute health check 64 | err := cache.HealthCheck(testCtx) 65 | assert.Nil(t, err) 66 | 67 | // Verify mock expectations 68 | err = mock.ExpectationsWereMet() 69 | assert.NoError(t, err) 70 | } 71 | -------------------------------------------------------------------------------- /adapter/repository/redis/testcontainer.go: -------------------------------------------------------------------------------- 1 | package redis 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strconv" 7 | "testing" 8 | 9 | "github.com/alicebob/miniredis/v2" 10 | "github.com/go-redis/redis/v8" 11 | 12 | "go-hexagonal/config" 13 | ) 14 | 15 | // SetupRedisContainer creates a Redis mock for testing using miniredis 16 | func SetupRedisContainer(t *testing.T) *config.RedisConfig { 17 | t.Helper() 18 | 19 | // Start miniredis server (in-memory implementation) 20 | s := miniredis.RunT(t) 21 | 22 | // Convert port to int 23 | portInt, err := strconv.Atoi(s.Port()) 24 | if err != nil { 25 | t.Fatalf("Failed to convert port to integer: %v", err) 26 | } 27 | 28 | // Create config using config.RedisConfig 29 | redisConfig := &config.RedisConfig{ 30 | Host: s.Host(), 31 | Port: portInt, 32 | Password: "", // No password for test instance 33 | DB: 0, 34 | PoolSize: 10, 35 | IdleTimeout: 300, 36 | MinIdleConns: 2, 37 | } 38 | 39 | // Return server instance via test cleanup to ensure proper shutdown 40 | t.Cleanup(func() { 41 | s.Close() 42 | }) 43 | 44 | return redisConfig 45 | } 46 | 47 | // GetRedisClient returns a Redis client for testing 48 | func GetRedisClient(t *testing.T, config *config.RedisConfig) *RedisClient { 49 | t.Helper() 50 | 51 | opts := ClientOptionsFromConfig(config) 52 | client, err := NewClient(opts) 53 | if err != nil { 54 | t.Fatalf("Failed to create Redis client: %v", err) 55 | } 56 | 57 | return client 58 | } 59 | 60 | // MockRedisData adds test data to Redis 61 | func MockRedisData(t *testing.T, client *RedisClient, data map[string]interface{}) { 62 | t.Helper() 63 | 64 | ctx := context.Background() 65 | 66 | // Clear existing data 67 | keys, err := client.Client.Keys(ctx, "*").Result() 68 | if err != nil { 69 | t.Fatalf("Failed to get Redis keys: %v", err) 70 | } 71 | 72 | if len(keys) > 0 { 73 | if _, err := client.Client.Del(ctx, keys...).Result(); err != nil { 74 | t.Fatalf("Failed to clear Redis data: %v", err) 75 | } 76 | } 77 | 78 | // Add the test data 79 | for k, v := range data { 80 | if err := client.Client.Set(ctx, k, v, 0).Err(); err != nil { 81 | t.Fatalf("Failed to set Redis data for key %s: %v", k, err) 82 | } 83 | } 84 | } 85 | 86 | // AssertRedisData checks if Redis data matches expected values 87 | func AssertRedisData(t *testing.T, client *RedisClient, key string, expected interface{}) { 88 | t.Helper() 89 | 90 | ctx := context.Background() 91 | 92 | // Get value from Redis 93 | val, err := client.Client.Get(ctx, key).Result() 94 | if err != nil { 95 | if err == redis.Nil { 96 | t.Fatalf("Key %s does not exist in Redis", key) 97 | } 98 | t.Fatalf("Failed to get Redis data for key %s: %v", key, err) 99 | } 100 | 101 | // Convert expected to string for comparison 102 | expectedStr := fmt.Sprintf("%v", expected) 103 | 104 | // Compare values 105 | if val != expectedStr { 106 | t.Fatalf("Redis value mismatch for key %s. Expected: %v, Got: %s", key, expected, val) 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /adapter/repository/redis/testcontainer_test.go: -------------------------------------------------------------------------------- 1 | package redis 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestSetupRedisContainer(t *testing.T) { 11 | // Create Redis mock 12 | config := SetupRedisContainer(t) 13 | 14 | // Validate configuration 15 | assert.NotEmpty(t, config.Host, "Host should not be empty") 16 | assert.NotZero(t, config.Port, "Port should be greater than 0") 17 | assert.Empty(t, config.Password, "Password should be empty for test instance") 18 | assert.Equal(t, 0, config.DB, "DB should be 0 for test instance") 19 | 20 | // Validate additional config fields 21 | assert.Equal(t, 10, config.PoolSize) 22 | assert.Equal(t, 300, config.IdleTimeout) 23 | assert.Equal(t, 2, config.MinIdleConns) 24 | 25 | // Get Redis client 26 | client := GetRedisClient(t, config) 27 | 28 | // Test redis connection by executing simple commands 29 | ctx := context.Background() 30 | err := client.Client.Set(ctx, "test_key", "test_value", 0).Err() 31 | assert.NoError(t, err, "Should be able to set a value") 32 | 33 | val, err := client.Client.Get(ctx, "test_key").Result() 34 | assert.NoError(t, err, "Should be able to get a value") 35 | assert.Equal(t, "test_value", val, "Value should match what was set") 36 | 37 | // Test MockRedisData function 38 | testData := map[string]interface{}{ 39 | "key1": "value1", 40 | "key2": 42, 41 | "key3": "1", 42 | } 43 | 44 | // Use the MockRedisData function 45 | MockRedisData(t, client, testData) 46 | 47 | // Verify data was inserted 48 | val, err = client.Client.Get(ctx, "key1").Result() 49 | assert.NoError(t, err, "Should be able to get key1") 50 | assert.Equal(t, "value1", val, "Value should match what was set") 51 | 52 | // Test AssertRedisData function 53 | AssertRedisData(t, client, "key1", "value1") 54 | AssertRedisData(t, client, "key2", 42) 55 | AssertRedisData(t, client, "key3", "1") 56 | } 57 | -------------------------------------------------------------------------------- /adapter/repository/repository.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "go.uber.org/zap" 9 | "gorm.io/gorm" 10 | 11 | "go-hexagonal/util/log" 12 | 13 | "github.com/go-redis/redis/v8" 14 | ) 15 | 16 | // DefaultRepositoryTimeout defines the default context timeout for database operations 17 | const DefaultRepositoryTimeout = 30 * time.Second 18 | 19 | // ClientContainer holds all repository client instances 20 | type ClientContainer struct { 21 | MySQL *MySQL 22 | Redis *Redis 23 | PostgreSQL *PostgreSQL 24 | } 25 | 26 | // Global instance for backward compatibility 27 | // Note: It's recommended to use dependency injection with Wire instead of this global instance 28 | var Clients = &ClientContainer{} 29 | 30 | // ISQLClient defines the interface for SQL database clients (MySQL, PostgreSQL) 31 | type ISQLClient interface { 32 | // GetDB returns the database instance with context 33 | GetDB(ctx context.Context) interface{} 34 | 35 | // SetDB sets the database instance 36 | SetDB(db interface{}) 37 | 38 | // Close closes the database connection 39 | Close(ctx context.Context) error 40 | } 41 | 42 | // IRedisClient defines the interface for Redis clients 43 | type IRedisClient interface { 44 | // Close closes the Redis connection 45 | Close(ctx context.Context) error 46 | } 47 | 48 | // Initialize creates a new client container if not already initialized 49 | func Initialize() { 50 | if Clients == nil { 51 | Clients = &ClientContainer{} 52 | } 53 | } 54 | 55 | // Close closes all repository connections 56 | func (c *ClientContainer) Close(ctx context.Context) { 57 | if c.MySQL != nil { 58 | if err := c.MySQL.Close(ctx); err != nil { 59 | log.Logger.Error("failed to close MySQL connection", zap.Error(err)) 60 | } 61 | } 62 | if c.PostgreSQL != nil { 63 | if err := c.PostgreSQL.Close(ctx); err != nil { 64 | log.Logger.Error("failed to close PostgreSQL connection", zap.Error(err)) 65 | } 66 | } 67 | if c.Redis != nil { 68 | if err := c.Redis.Close(ctx); err != nil { 69 | log.Logger.Error("failed to close Redis connection", zap.Error(err)) 70 | } 71 | } 72 | } 73 | 74 | // Close closes all repository connections 75 | func Close(ctx context.Context) { 76 | ctx, cancel := context.WithTimeout(ctx, DefaultRepositoryTimeout) 77 | defer cancel() 78 | 79 | Clients.Close(ctx) 80 | 81 | log.Logger.Info("repository closed") 82 | } 83 | 84 | // MySQL represents a MySQL database client 85 | type MySQL struct { 86 | DB *gorm.DB 87 | } 88 | 89 | // SetDB sets the GORM database connection 90 | func (m *MySQL) SetDB(db *gorm.DB) { 91 | m.DB = db 92 | } 93 | 94 | // GetDB returns the GORM database connection 95 | func (m *MySQL) GetDB(ctx context.Context) *gorm.DB { 96 | if m.DB == nil { 97 | return nil 98 | } 99 | return m.DB.WithContext(ctx) 100 | } 101 | 102 | // Close closes the MySQL connection 103 | func (m *MySQL) Close(ctx context.Context) error { 104 | // No-op for now, as GORM manages connection pooling 105 | return nil 106 | } 107 | 108 | // NewMySQLClient creates a new MySQL client 109 | func NewMySQLClient(db *gorm.DB) *MySQL { 110 | return &MySQL{DB: db} 111 | } 112 | 113 | // PostgreSQL represents a PostgreSQL database client 114 | type PostgreSQL struct { 115 | DB *gorm.DB 116 | } 117 | 118 | // SetDB sets the GORM database connection 119 | func (p *PostgreSQL) SetDB(db *gorm.DB) { 120 | p.DB = db 121 | } 122 | 123 | // GetDB returns the GORM database connection 124 | func (p *PostgreSQL) GetDB(ctx context.Context) *gorm.DB { 125 | if p.DB == nil { 126 | return nil 127 | } 128 | return p.DB.WithContext(ctx) 129 | } 130 | 131 | // Close closes the PostgreSQL connection 132 | func (p *PostgreSQL) Close(ctx context.Context) error { 133 | // No-op for now, as GORM manages connection pooling 134 | return nil 135 | } 136 | 137 | // NewPostgreSQLClient creates a new PostgreSQL client 138 | func NewPostgreSQLClient(db *gorm.DB) *PostgreSQL { 139 | return &PostgreSQL{DB: db} 140 | } 141 | 142 | // Redis represents a Redis client 143 | type Redis struct { 144 | DB *redis.Client 145 | } 146 | 147 | // Close closes the Redis connection 148 | func (r *Redis) Close(ctx context.Context) error { 149 | if r.DB != nil { 150 | if err := r.DB.Close(); err != nil { 151 | return fmt.Errorf("failed to close Redis connection: %w", err) 152 | } 153 | } 154 | return nil 155 | } 156 | 157 | // NewRedisClient creates a new Redis client 158 | func NewRedisClient() *Redis { 159 | return &Redis{} 160 | } 161 | -------------------------------------------------------------------------------- /adapter/repository/repository_plan.md: -------------------------------------------------------------------------------- 1 | # Repository Implementation Plan 2 | 3 | We're implementing the adapters for MySQL, PostgreSQL, and Redis following the hexagonal architecture pattern. 4 | 5 | ## Files Created 6 | 7 | ### MySQL Implementation 8 | - ✅ adapter/repository/mysql/client.go - MySQL client implementation 9 | - ✅ adapter/repository/mysql/example_repo.go - MySQL example repository implementation 10 | - ✅ adapter/repository/mysql/testcontainer.go - MySQL test container for testing 11 | 12 | ### PostgreSQL Implementation 13 | - ✅ adapter/repository/postgre/client.go - PostgreSQL client implementation using GORM 14 | - ✅ adapter/repository/postgre/example_repo.go - PostgreSQL example repository implementation 15 | - ✅ adapter/repository/postgre/testcontainer.go - PostgreSQL test container for testing 16 | 17 | ### Redis Implementation 18 | - ✅ adapter/repository/redis/client.go - Redis client implementation 19 | - ✅ adapter/repository/redis/example_cache.go - Redis example cache implementation 20 | - ✅ adapter/repository/redis/testcontainer.go - Redis test container for testing 21 | 22 | ### Common Files 23 | - ✅ adapter/repository/error.go - Repository errors 24 | - ✅ adapter/repository/transaction.go - Transaction implementation 25 | - ✅ adapter/repository/factory.go - Factory for creating repositories 26 | 27 | ## Implementation Details 28 | 29 | 1. All implementations follow hexagonal architecture: 30 | - Domain defines interfaces (already exists) 31 | - Repository implementations adapt external technologies to domain interfaces 32 | 33 | 2. Test Containers for Integration Testing: 34 | - Used github.com/testcontainers/testcontainers-go for containerized database testing 35 | - Each database has its own testcontainer implementation with initialization scripts 36 | 37 | 3. SQL Schema Notes: 38 | - MySQL schema from direct SQL in testcontainer setup 39 | - PostgreSQL schema is created with similar but PostgreSQL-specific syntax 40 | 41 | 4. Transaction Support: 42 | - All repositories support transactions through the Transaction interface 43 | - getDB/getConn methods handle extracting the connection from transactions or using the default client 44 | - Reflection-based approach used to avoid circular dependencies 45 | 46 | ## Design Patterns Used 47 | 48 | 1. **Repository Pattern**: Clear separation between domain models and data access 49 | 2. **Adapter Pattern**: Database implementations adapt external storage to domain interfaces 50 | 3. **Factory Pattern**: TransactionFactory creates transactions for different storage types 51 | 4. **Dependency Injection**: Repositories accept clients as dependencies 52 | 53 | ## Code Features 54 | 55 | 1. **Consistent error handling**: Standardized error types and error wrapping with context 56 | 2. **English comments**: All code is documented with clear English comments 57 | 3. **GORM usage for both MySQL and PostgreSQL**: Unified ORM approach where applicable 58 | 4. **Testcontainers for testing**: Consistent approach to database testing with containerization 59 | 5. **Generic transaction handling**: Common transaction interface with store-specific implementations 60 | -------------------------------------------------------------------------------- /adapter/repository/repository_test.go: -------------------------------------------------------------------------------- 1 | package repository_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/mock" 9 | "go.uber.org/zap" 10 | "gorm.io/gorm" 11 | 12 | "go-hexagonal/adapter/repository" 13 | "go-hexagonal/util/log" 14 | ) 15 | 16 | // MockDB is a mock implementation of gorm.DB 17 | type MockDB struct { 18 | mock.Mock 19 | } 20 | 21 | func TestNewMySQLClient(t *testing.T) { 22 | // Create a nil db 23 | var db *gorm.DB = nil 24 | 25 | // Create MySQL client 26 | mysqlClient := repository.NewMySQLClient(db) 27 | 28 | // Verify client is not nil 29 | assert.NotNil(t, mysqlClient) 30 | 31 | // Verify GetDB returns nil (because db itself is nil) 32 | ctx := context.Background() 33 | assert.Nil(t, mysqlClient.GetDB(ctx)) 34 | 35 | // Verify Close method doesn't return an error 36 | err := mysqlClient.Close(ctx) 37 | assert.NoError(t, err) 38 | } 39 | 40 | func TestNewPostgreSQLClient(t *testing.T) { 41 | // Create a nil db 42 | var db *gorm.DB = nil 43 | 44 | // Create PostgreSQL client 45 | pgClient := repository.NewPostgreSQLClient(db) 46 | 47 | // Verify client is not nil 48 | assert.NotNil(t, pgClient) 49 | 50 | // Verify GetDB returns nil (because db itself is nil) 51 | ctx := context.Background() 52 | assert.Nil(t, pgClient.GetDB(ctx)) 53 | 54 | // Verify Close method doesn't return an error 55 | err := pgClient.Close(ctx) 56 | assert.NoError(t, err) 57 | } 58 | 59 | func TestNewRedisClient(t *testing.T) { 60 | // Create Redis client 61 | redisClient := repository.NewRedisClient() 62 | 63 | // Verify client is not nil 64 | assert.NotNil(t, redisClient) 65 | 66 | // Verify Close method doesn't return an error 67 | ctx := context.Background() 68 | err := redisClient.Close(ctx) 69 | assert.NoError(t, err) 70 | } 71 | 72 | func TestClientContainer_Close(t *testing.T) { 73 | // Create ClientContainer and set test clients 74 | container := &repository.ClientContainer{ 75 | MySQL: repository.NewMySQLClient(nil), 76 | PostgreSQL: repository.NewPostgreSQLClient(nil), 77 | Redis: repository.NewRedisClient(), 78 | } 79 | 80 | // Verify Close method doesn't panic 81 | ctx := context.Background() 82 | assert.NotPanics(t, func() { 83 | container.Close(ctx) 84 | }) 85 | } 86 | 87 | func TestClose(t *testing.T) { 88 | // Save original Logger 89 | originalLogger := log.Logger 90 | defer func() { 91 | // Restore original Logger after test 92 | log.Logger = originalLogger 93 | }() 94 | 95 | // Set a temporary Logger 96 | log.Logger = zap.NewNop() 97 | 98 | // Ensure Close function doesn't panic 99 | ctx := context.Background() 100 | assert.NotPanics(t, func() { 101 | repository.Close(ctx) 102 | }) 103 | } 104 | -------------------------------------------------------------------------------- /adapter/repository/transaction.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | "reflect" 8 | "time" 9 | 10 | "go-hexagonal/domain/repo" 11 | 12 | "gorm.io/gorm" 13 | ) 14 | 15 | // DefaultTimeout defines the default context timeout for database operations 16 | const DefaultTimeout = 30 * time.Second 17 | 18 | // Transaction represents a database transaction 19 | type Transaction struct { 20 | ctx context.Context 21 | Session *gorm.DB 22 | TxOpt *sql.TxOptions 23 | store repo.StoreType 24 | options *repo.TransactionOptions 25 | } 26 | 27 | // Conn returns a database connection with transaction support 28 | func (tr *Transaction) Conn() any { 29 | if tr == nil { 30 | return nil 31 | } 32 | if tr.Session == nil { 33 | return nil 34 | } 35 | return tr.Session.WithContext(tr.ctx) 36 | } 37 | 38 | // Begin starts a new transaction 39 | func (tr *Transaction) Begin() error { 40 | if tr == nil { 41 | return ErrInvalidTransaction 42 | } 43 | if tr.Session == nil { 44 | return ErrInvalidSession 45 | } 46 | // Begin a new transaction 47 | tr.Session = tr.Session.Begin(tr.TxOpt) 48 | if tr.Session.Error != nil { 49 | return fmt.Errorf("failed to begin transaction: %w", tr.Session.Error) 50 | } 51 | return nil 52 | } 53 | 54 | // Commit commits the transaction 55 | func (tr *Transaction) Commit() error { 56 | if tr != nil && tr.Session != nil { 57 | return tr.Session.Commit().Error 58 | } 59 | return nil 60 | } 61 | 62 | // Rollback rolls back the transaction 63 | func (tr *Transaction) Rollback() error { 64 | if tr != nil && tr.Session != nil { 65 | return tr.Session.Rollback().Error 66 | } 67 | return nil 68 | } 69 | 70 | // WithContext returns a new transaction with the given context 71 | func (tr *Transaction) WithContext(ctx context.Context) repo.Transaction { 72 | if tr == nil { 73 | return nil 74 | } 75 | newTr := *tr 76 | newTr.ctx = ctx 77 | if newTr.Session != nil { 78 | newTr.Session = newTr.Session.WithContext(ctx) 79 | } 80 | return &newTr 81 | } 82 | 83 | // Context returns the transaction's context 84 | func (tr *Transaction) Context() context.Context { 85 | return tr.ctx 86 | } 87 | 88 | // StoreType returns the store type 89 | func (tr *Transaction) StoreType() repo.StoreType { 90 | return tr.store 91 | } 92 | 93 | // Options returns the transaction options 94 | func (tr *Transaction) Options() *repo.TransactionOptions { 95 | return tr.options 96 | } 97 | 98 | // NewTransaction creates a new transaction with the specified store type and options 99 | func NewTransaction(ctx context.Context, store StoreType, client any, sqlOpt *sql.TxOptions) (*Transaction, error) { 100 | // Convert store type to domain store type 101 | domainStore := repo.StoreType(store) 102 | 103 | // Convert SQL options to transaction options 104 | var options *repo.TransactionOptions 105 | if sqlOpt != nil { 106 | options = &repo.TransactionOptions{ 107 | ReadOnly: sqlOpt.ReadOnly, 108 | } 109 | } else { 110 | options = repo.DefaultTransactionOptions() 111 | } 112 | 113 | tr := &Transaction{ 114 | ctx: ctx, 115 | TxOpt: sqlOpt, 116 | store: domainStore, 117 | options: options, 118 | } 119 | 120 | switch store { 121 | case MySQLStore, PostgreSQLStore: 122 | // Handle SQL-based databases with GORM 123 | var db *gorm.DB 124 | 125 | // Use reflection to check type and call appropriate method 126 | clientValue := reflect.ValueOf(client) 127 | if clientValue.Kind() == reflect.Ptr && !clientValue.IsNil() { 128 | // Try to call GetDB method 129 | method := clientValue.MethodByName("GetDB") 130 | if method.IsValid() { 131 | result := method.Call([]reflect.Value{reflect.ValueOf(ctx)}) 132 | if len(result) > 0 && !result[0].IsNil() { 133 | if gormDB, ok := result[0].Interface().(*gorm.DB); ok { 134 | db = gormDB 135 | } 136 | } 137 | } 138 | } 139 | 140 | if db == nil { 141 | return nil, fmt.Errorf("failed to get database session") 142 | } 143 | // Initialize the session 144 | tr.Session = db 145 | case RedisStore: 146 | // Redis transactions would be implemented here 147 | return nil, fmt.Errorf("redis transaction not implemented") 148 | default: 149 | return nil, ErrUnsupportedStoreType 150 | } 151 | 152 | return tr, nil 153 | } 154 | 155 | // StoreType defines the type of storage 156 | type StoreType string 157 | 158 | // Available store types 159 | const ( 160 | MySQLStore StoreType = "MySQL" 161 | RedisStore StoreType = "Redis" 162 | PostgreSQLStore StoreType = "PostgreSQL" 163 | ) 164 | -------------------------------------------------------------------------------- /api/dto/dto.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | type Pager struct { 4 | Page int `json:"page"` 5 | PageSize int `json:"page_size"` 6 | TotalRows int `json:"total_rows"` 7 | } 8 | -------------------------------------------------------------------------------- /api/dto/example.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type CreateExampleReq struct { 8 | Name string `json:"name" binding:"required" message:"name is a required field"` 9 | Alias string `json:"alias" binding:"required" message:"alias is a required field"` 10 | } 11 | 12 | type CreateExampleResp struct { 13 | Id uint `json:"id"` 14 | Name string `json:"name"` 15 | Alias string `json:"alias"` 16 | CreatedAt time.Time `json:"created_at"` 17 | UpdatedAt time.Time `json:"updated_at"` 18 | } 19 | 20 | type DeleteExampleReq struct { 21 | Id int `uri:"id" binding:"required"` 22 | } 23 | 24 | type UpdateExampleReq struct { 25 | Id uint `uri:"id"` 26 | Name string `json:"name"` 27 | Alias string `json:"alias"` 28 | } 29 | 30 | type GetExampleReq struct { 31 | Id int `uri:"id" binding:"required"` 32 | } 33 | 34 | type GetExampleResponse struct { 35 | Id int `uri:"id"` 36 | Name string `json:"name"` 37 | Alias string `json:"alias"` 38 | CreatedAt time.Time `json:"created_at"` 39 | UpdatedAt time.Time `json:"updated_at"` 40 | } 41 | -------------------------------------------------------------------------------- /api/error_code/error_code.go: -------------------------------------------------------------------------------- 1 | package error_code 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | ) 7 | 8 | // Error represents a standardized API error 9 | type Error struct { 10 | Code int `json:"code"` // Error code 11 | Msg string `json:"message"` // Error message 12 | Details []string `json:"details,omitempty"` // Optional error details 13 | HTTP int `json:"-"` // HTTP status code (not exposed in JSON) 14 | DocRef string `json:"doc_ref,omitempty"` // Reference to documentation 15 | } 16 | 17 | var codes = map[int]string{} 18 | 19 | // Basic error code 20 | const ( 21 | SuccessCode = 0 22 | 23 | ServerErrorCode = 10000 24 | InvalidParamsCode = 10001 25 | NotFoundCode = 10002 26 | TooManyRequestsCode = 10003 27 | 28 | UnauthorizedAuthNotExistErrorCode = 20001 29 | UnauthorizedTokenErrorCode = 20002 30 | UnauthorizedTokenTimeoutErrorCode = 20003 31 | UnauthorizedTokenGenerateErrorCode = 20004 32 | 33 | CopyErrorErrorCode = 30001 34 | JSONErrorErrorCode = 30002 35 | 36 | AccountExistErrorCode = 40001 37 | UserNameExistErrorCode = 40002 38 | ) 39 | 40 | // API error code 41 | var ( 42 | Success = NewError(SuccessCode, "success") 43 | ServerError = NewError(ServerErrorCode, "server internal error") 44 | InvalidParams = NewError(InvalidParamsCode, "invalid params") 45 | NotFound = NewError(NotFoundCode, "record not found") 46 | TooManyRequests = NewError(TooManyRequestsCode, "too many requests") 47 | ) 48 | 49 | // Auth error code 50 | var ( 51 | UnauthorizedAuthNotExist = NewError(UnauthorizedAuthNotExistErrorCode, "unauthorized, auth not exists") 52 | UnauthorizedTokenError = NewError(UnauthorizedTokenErrorCode, "unauthorized, token invalid") 53 | UnauthorizedTokenTimeout = NewError(UnauthorizedTokenTimeoutErrorCode, "unauthorized, token timeout") 54 | UnauthorizedTokenGenerate = NewError(UnauthorizedTokenGenerateErrorCode, "unauthorized, token generate failed") 55 | ) 56 | 57 | // Internal error code 58 | var ( 59 | CopyError = NewError(CopyErrorErrorCode, "copy obj error") 60 | JSONError = NewError(JSONErrorErrorCode, "json marshal/unmarshal error") 61 | ) 62 | 63 | // Business error code 64 | var ( 65 | AccountExist = NewError(AccountExistErrorCode, "account already exists") 66 | UserNameExist = NewError(UserNameExistErrorCode, "username already exists") 67 | ) 68 | 69 | // NewError creates a new Error instance with the specified code and message 70 | func NewError(code int, msg string) *Error { 71 | if _, ok := codes[code]; ok { 72 | panic(fmt.Sprintf("error code %d already exists, please replace one", code)) 73 | } 74 | 75 | codes[code] = msg 76 | return &Error{ 77 | Code: code, 78 | Msg: msg, 79 | HTTP: determineHTTPStatusCode(code), // Determine default HTTP status code 80 | } 81 | } 82 | 83 | // NewErrorWithStatus creates a new Error instance with the specified code, message, and HTTP status 84 | func NewErrorWithStatus(code int, msg string, status int) *Error { 85 | if _, ok := codes[code]; ok { 86 | panic(fmt.Sprintf("error code %d already exists, please replace one", code)) 87 | } 88 | 89 | codes[code] = msg 90 | return &Error{ 91 | Code: code, 92 | Msg: msg, 93 | HTTP: status, 94 | } 95 | } 96 | 97 | // Error implements the error interface 98 | func (e Error) Error() string { 99 | return fmt.Sprintf("err_code: %d, err_msg: %s, details: %v", e.Code, e.Msg, e.Details) 100 | } 101 | 102 | // Msgf formats the error message with the provided arguments 103 | func (e Error) Msgf(args []any) string { 104 | return fmt.Sprintf(e.Msg, args...) 105 | } 106 | 107 | // WithDetails adds error details 108 | func (e *Error) WithDetails(details ...string) *Error { 109 | newError := *e 110 | newError.Details = []string{} 111 | newError.Details = append(newError.Details, details...) 112 | 113 | return &newError 114 | } 115 | 116 | // WithDocRef adds a documentation reference 117 | func (e *Error) WithDocRef(docRef string) *Error { 118 | newError := *e 119 | newError.DocRef = docRef 120 | return &newError 121 | } 122 | 123 | // WithMessage customizes the error message 124 | func (e *Error) WithMessage(format string, args ...interface{}) *Error { 125 | newError := *e 126 | newError.Msg = fmt.Sprintf(format, args...) 127 | return &newError 128 | } 129 | 130 | // Is implements error comparison 131 | func (e *Error) Is(tgt error) bool { 132 | target, ok := tgt.(*Error) 133 | if !ok { 134 | return false 135 | } 136 | return target.Code == e.Code 137 | } 138 | 139 | // StatusCode returns the HTTP status code 140 | func (e *Error) StatusCode() int { 141 | if e.HTTP != 0 { 142 | return e.HTTP 143 | } 144 | return determineHTTPStatusCode(e.Code) 145 | } 146 | 147 | // determineHTTPStatusCode maps error codes to HTTP status codes 148 | func determineHTTPStatusCode(code int) int { 149 | switch code { 150 | case SuccessCode: 151 | return http.StatusOK 152 | case ServerErrorCode: 153 | return http.StatusInternalServerError 154 | case InvalidParamsCode: 155 | return http.StatusBadRequest 156 | case NotFoundCode: 157 | return http.StatusNotFound 158 | case UnauthorizedAuthNotExistErrorCode, 159 | UnauthorizedTokenErrorCode, 160 | UnauthorizedTokenGenerateErrorCode, 161 | UnauthorizedTokenTimeoutErrorCode: 162 | return http.StatusUnauthorized 163 | case TooManyRequestsCode: 164 | return http.StatusTooManyRequests 165 | default: 166 | return http.StatusInternalServerError 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /api/grpc/grpc_server.go: -------------------------------------------------------------------------------- 1 | package grpc 2 | -------------------------------------------------------------------------------- /api/http/converter.go: -------------------------------------------------------------------------------- 1 | package http 2 | -------------------------------------------------------------------------------- /api/http/handle/handle.go: -------------------------------------------------------------------------------- 1 | package handle 2 | 3 | import ( 4 | "net/http" 5 | "reflect" 6 | "strconv" 7 | 8 | "github.com/gin-gonic/gin" 9 | 10 | "go-hexagonal/api/dto" 11 | "go-hexagonal/api/error_code" 12 | "go-hexagonal/api/http/paginate" 13 | "go-hexagonal/util/log" 14 | ) 15 | 16 | type Response struct { 17 | Ctx *gin.Context 18 | } 19 | 20 | // StandardResponse defines the standard API response structure 21 | type StandardResponse struct { 22 | Code int `json:"code"` // Status code 23 | Message string `json:"message"` // Response message 24 | Data interface{} `json:"data,omitempty"` // Response data 25 | DocRef string `json:"doc_ref,omitempty"` // Documentation reference 26 | } 27 | 28 | func NewResponse(ctx *gin.Context) *Response { 29 | return &Response{Ctx: ctx} 30 | } 31 | 32 | func (r *Response) ToResponse(data interface{}) { 33 | if data == nil { 34 | data = gin.H{} 35 | } 36 | r.Ctx.JSON(http.StatusOK, StandardResponse{ 37 | Code: 0, 38 | Message: "success", 39 | Data: data, 40 | }) 41 | } 42 | 43 | func (r *Response) ToResponseList(list interface{}, totalRows int) { 44 | r.Ctx.JSON(http.StatusOK, StandardResponse{ 45 | Code: 0, 46 | Message: "success", 47 | Data: gin.H{ 48 | "list": list, 49 | "pager": dto.Pager{ 50 | Page: paginate.GetPage(r.Ctx), 51 | PageSize: paginate.GetPageSize(r.Ctx), 52 | TotalRows: totalRows, 53 | }, 54 | }, 55 | }) 56 | } 57 | 58 | func (r *Response) ToErrorResponse(err *error_code.Error) { 59 | response := StandardResponse{ 60 | Code: err.Code, 61 | Message: err.Msg, 62 | } 63 | 64 | if len(err.Details) > 0 { 65 | response.Data = gin.H{"details": err.Details} 66 | } 67 | 68 | if err.DocRef != "" { 69 | response.DocRef = err.DocRef 70 | } 71 | 72 | r.Ctx.JSON(err.StatusCode(), response) 73 | } 74 | 75 | // Success returns a success response 76 | func Success(c *gin.Context, data any) { 77 | c.JSON(http.StatusOK, StandardResponse{ 78 | Code: 0, 79 | Message: "success", 80 | Data: data, 81 | }) 82 | } 83 | 84 | // Error unified error handling 85 | func Error(c *gin.Context, err error) { 86 | // Handle API error codes 87 | if apiErr, ok := err.(*error_code.Error); ok { 88 | c.JSON(apiErr.StatusCode(), StandardResponse{ 89 | Code: apiErr.Code, 90 | Message: apiErr.Msg, 91 | Data: gin.H{"details": apiErr.Details}, 92 | DocRef: apiErr.DocRef, 93 | }) 94 | return 95 | } 96 | 97 | // Log unexpected errors 98 | log.SugaredLogger.Errorf("Unexpected error: %v", err) 99 | 100 | // Default error response 101 | c.JSON(http.StatusInternalServerError, StandardResponse{ 102 | Code: error_code.ServerErrorCode, 103 | Message: "Internal server error", 104 | }) 105 | } 106 | 107 | // GetQueryInt gets an integer from query parameters with a default value 108 | func GetQueryInt(c *gin.Context, key string, defaultValue int) int { 109 | value, exists := c.GetQuery(key) 110 | if !exists { 111 | return defaultValue 112 | } 113 | intValue, err := strconv.Atoi(value) 114 | if err != nil { 115 | return defaultValue 116 | } 117 | return intValue 118 | } 119 | 120 | // GetQueryString gets a string from query parameters with a default value 121 | func GetQueryString(c *gin.Context, key string, defaultValue string) string { 122 | value, exists := c.GetQuery(key) 123 | if !exists { 124 | return defaultValue 125 | } 126 | return value 127 | } 128 | 129 | // IsNil checks if an interface is nil or its underlying value is nil 130 | func IsNil(i any) bool { 131 | if i == nil { 132 | return true 133 | } 134 | switch reflect.TypeOf(i).Kind() { 135 | case reflect.Ptr, reflect.Map, reflect.Array, reflect.Chan, reflect.Slice: 136 | return reflect.ValueOf(i).IsNil() 137 | } 138 | return false 139 | } 140 | -------------------------------------------------------------------------------- /api/http/handle/main_test.go: -------------------------------------------------------------------------------- 1 | package handle 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/gin-gonic/gin" 8 | "go.uber.org/zap" 9 | 10 | "go-hexagonal/util/log" 11 | ) 12 | 13 | func TestMain(m *testing.M) { 14 | // 设置Gin为测试模式 15 | gin.SetMode(gin.TestMode) 16 | 17 | // 初始化日志配置 18 | initTestLogger() 19 | 20 | // 运行测试 21 | exitCode := m.Run() 22 | 23 | // 退出 24 | os.Exit(exitCode) 25 | } 26 | 27 | // initTestLogger 初始化测试环境的日志配置 28 | func initTestLogger() { 29 | // 使用最简单的控制台日志配置 30 | logger, _ := zap.NewDevelopment() 31 | zap.ReplaceGlobals(logger) 32 | 33 | // 初始化全局日志变量 34 | log.Logger = logger 35 | log.SugaredLogger = logger.Sugar() 36 | } 37 | -------------------------------------------------------------------------------- /api/http/main_test.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "fmt" 7 | "os" 8 | "testing" 9 | 10 | "go-hexagonal/adapter/dependency" 11 | "go-hexagonal/adapter/repository" 12 | "go-hexagonal/adapter/repository/mysql" 13 | "go-hexagonal/adapter/repository/mysql/entity" 14 | "go-hexagonal/adapter/repository/redis" 15 | "go-hexagonal/config" 16 | "go-hexagonal/util/log" 17 | ) 18 | 19 | var ctx = context.TODO() 20 | 21 | func TestMain(m *testing.M) { 22 | // Parse command line arguments, support -short flag 23 | flag.Parse() 24 | 25 | // Initialize configuration and logging 26 | config.Init("../../config", "config") 27 | log.Init() 28 | 29 | // Skip integration tests in short mode 30 | if testing.Short() { 31 | fmt.Println("Skipping integration tests in short mode") 32 | os.Exit(0) 33 | return 34 | } 35 | 36 | // Use test containers 37 | t := &testing.T{} 38 | mysqlConfig := mysql.SetupMySQLContainer(t) 39 | redisConfig := redis.SetupRedisContainer(t) 40 | 41 | // Set global config to use test containers 42 | config.GlobalConfig.MySQL = mysqlConfig 43 | config.GlobalConfig.Redis = redisConfig 44 | 45 | // Initialize repositories using dependency injection 46 | clients, err := dependency.InitializeRepositories( 47 | dependency.WithMySQL(), 48 | dependency.WithRedis(), 49 | ) 50 | if err != nil { 51 | log.SugaredLogger.Fatalf("Failed to initialize repositories: %v", err) 52 | } 53 | repository.Clients = clients 54 | _ = repository.Clients.MySQL.GetDB(ctx).AutoMigrate(&entity.Example{}) 55 | 56 | // Initialize services using dependency injection 57 | svcs, err := dependency.InitializeServices(ctx, dependency.WithExampleService()) 58 | if err != nil { 59 | log.SugaredLogger.Fatalf("Failed to initialize services: %v", err) 60 | } 61 | 62 | // Register services for API handlers 63 | RegisterServices(svcs) 64 | 65 | // Run tests 66 | os.Exit(m.Run()) 67 | } 68 | -------------------------------------------------------------------------------- /api/http/middleware/cors.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/gin-contrib/cors" 7 | "github.com/gin-gonic/gin" 8 | ) 9 | 10 | // CORS related constants 11 | const ( 12 | // CORSMaxAge defines the maximum age for CORS preflight requests 13 | CORSMaxAge = 12 * time.Hour 14 | ) 15 | 16 | // Cors provides a CORS middleware 17 | func Cors() gin.HandlerFunc { 18 | return cors.New(cors.Config{ 19 | AllowOrigins: []string{"*"}, 20 | AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, 21 | AllowHeaders: []string{"Origin", "Authorization", "Content-Type"}, 22 | ExposeHeaders: []string{"Content-Length"}, 23 | AllowCredentials: true, 24 | MaxAge: CORSMaxAge, 25 | }) 26 | } 27 | -------------------------------------------------------------------------------- /api/http/middleware/error_handler.go: -------------------------------------------------------------------------------- 1 | // Package middleware provides HTTP request processing middleware 2 | package middleware 3 | 4 | import ( 5 | "net/http" 6 | 7 | "github.com/gin-gonic/gin" 8 | 9 | "go-hexagonal/api/error_code" 10 | "go-hexagonal/util/errors" 11 | ) 12 | 13 | // ErrorHandlerMiddleware handles API layer error responses uniformly 14 | func ErrorHandlerMiddleware() gin.HandlerFunc { 15 | return func(c *gin.Context) { 16 | c.Next() 17 | 18 | // Check if there are any errors 19 | if len(c.Errors) > 0 { 20 | err := c.Errors.Last().Err 21 | 22 | // Return appropriate HTTP status code and error message based on error type 23 | switch { 24 | case errors.IsValidationError(err): 25 | c.JSON(http.StatusBadRequest, gin.H{ 26 | "code": error_code.InvalidParamsCode, 27 | "message": err.Error(), 28 | }) 29 | return 30 | 31 | case errors.IsNotFoundError(err): 32 | c.JSON(http.StatusNotFound, gin.H{ 33 | "code": error_code.NotFoundCode, 34 | "message": err.Error(), 35 | }) 36 | return 37 | 38 | case errors.IsPersistenceError(err): 39 | c.JSON(http.StatusInternalServerError, gin.H{ 40 | "code": error_code.ServerErrorCode, 41 | "message": "Database operation failed", 42 | }) 43 | return 44 | 45 | default: 46 | // Default server error 47 | c.JSON(http.StatusInternalServerError, gin.H{ 48 | "code": error_code.ServerErrorCode, 49 | "message": "Internal server error", 50 | }) 51 | } 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /api/http/middleware/main_test.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "go-hexagonal/config" 8 | "go-hexagonal/util/log" 9 | ) 10 | 11 | func TestMain(m *testing.M) { 12 | config.Init("../../../config", "config") 13 | log.Init() 14 | 15 | exitCode := m.Run() 16 | os.Exit(exitCode) 17 | } 18 | -------------------------------------------------------------------------------- /api/http/middleware/middleware.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "net/http" 7 | "strings" 8 | 9 | "github.com/gin-gonic/gin" 10 | 11 | "go-hexagonal/util/log" 12 | ) 13 | 14 | func Trigger(types ...int) gin.HandlerFunc { 15 | return func(ctx *gin.Context) { 16 | if len(types) == 0 { 17 | return 18 | } 19 | 20 | path := ctx.FullPath() 21 | var body []byte 22 | // cache JSON data and rewrite to Request.Body 23 | if ctx.Request.Method == http.MethodPut && strings.HasPrefix(path, "/specific_url") { 24 | body, _ = ctx.GetRawData() 25 | ctx.Request.Body = io.NopCloser(bytes.NewBuffer(body)) 26 | } 27 | 28 | ctx.Next() 29 | 30 | // check if any business errors 31 | if ctx.Errors != nil { 32 | log.SugaredLogger.Errorf("NotifyTrigger fail due to business error, err: %s", ctx.Errors.String()) 33 | 34 | return 35 | } 36 | 37 | for _, notifyType := range types { 38 | // logic handle 39 | log.SugaredLogger.Infof("handle type %d", notifyType) 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /api/http/middleware/middleware_test.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "github.com/gin-gonic/gin" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestTrigger(t *testing.T) { 13 | // it's an example unit test for middleware 14 | 15 | t.Run("Exp start", func(t *testing.T) { 16 | w := httptest.NewRecorder() 17 | ctx, engine := gin.CreateTestContext(w) 18 | 19 | engine.GET("/test", 20 | Trigger(1), 21 | func(ctx *gin.Context) { 22 | }) 23 | 24 | ctx.Request, _ = http.NewRequest(http.MethodGet, "/test", nil) 25 | ctx.Request.AddCookie(&http.Cookie{ 26 | Name: "token", 27 | Value: "root-token", 28 | Path: "/", 29 | }) 30 | engine.HandleContext(ctx) 31 | assert.Nil(t, ctx.Errors) 32 | }) 33 | } 34 | -------------------------------------------------------------------------------- /api/http/middleware/pprof.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "net/http/pprof" 5 | 6 | "github.com/gin-gonic/gin" 7 | ) 8 | 9 | // RegisterPprof registers pprof handlers to a gin router 10 | func RegisterPprof(r *gin.Engine) { 11 | // Register pprof handlers 12 | pprofGroup := r.Group("/debug/pprof") 13 | { 14 | pprofGroup.GET("/", gin.WrapF(pprof.Index)) 15 | pprofGroup.GET("/cmdline", gin.WrapF(pprof.Cmdline)) 16 | pprofGroup.GET("/profile", gin.WrapF(pprof.Profile)) 17 | pprofGroup.POST("/symbol", gin.WrapF(pprof.Symbol)) 18 | pprofGroup.GET("/symbol", gin.WrapF(pprof.Symbol)) 19 | pprofGroup.GET("/trace", gin.WrapF(pprof.Trace)) 20 | pprofGroup.GET("/allocs", gin.WrapH(pprof.Handler("allocs"))) 21 | pprofGroup.GET("/block", gin.WrapH(pprof.Handler("block"))) 22 | pprofGroup.GET("/goroutine", gin.WrapH(pprof.Handler("goroutine"))) 23 | pprofGroup.GET("/heap", gin.WrapH(pprof.Handler("heap"))) 24 | pprofGroup.GET("/mutex", gin.WrapH(pprof.Handler("mutex"))) 25 | pprofGroup.GET("/threadcreate", gin.WrapH(pprof.Handler("threadcreate"))) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /api/http/middleware/request_id.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "github.com/google/uuid" 6 | ) 7 | 8 | const ( 9 | // RequestIDHeader is the header key for request ID 10 | RequestIDHeader = "X-Request-ID" 11 | ) 12 | 13 | // RequestID is a middleware that injects a request ID into the context of each request 14 | func RequestID() gin.HandlerFunc { 15 | return func(c *gin.Context) { 16 | // Get request ID from header or generate a new one 17 | requestID := c.GetHeader(RequestIDHeader) 18 | if requestID == "" { 19 | requestID = uuid.New().String() 20 | } 21 | 22 | // Set request ID to header 23 | c.Writer.Header().Set(RequestIDHeader, requestID) 24 | c.Set(RequestIDHeader, requestID) 25 | 26 | c.Next() 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /api/http/middleware/request_logger.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "net/http" 7 | "time" 8 | 9 | "github.com/gin-gonic/gin" 10 | "go.uber.org/zap" 11 | 12 | "go-hexagonal/util/log" 13 | ) 14 | 15 | // ResponseWriter is a wrapper for gin.ResponseWriter that captures the 16 | // response status code and size 17 | type ResponseWriter struct { 18 | gin.ResponseWriter 19 | body *bytes.Buffer 20 | statusCode int 21 | } 22 | 23 | // Write captures the response body and writes it to the underlying writer 24 | func (rw *ResponseWriter) Write(b []byte) (int, error) { 25 | rw.body.Write(b) 26 | return rw.ResponseWriter.Write(b) 27 | } 28 | 29 | // WriteHeader captures the status code and calls the underlying WriteHeader 30 | func (rw *ResponseWriter) WriteHeader(statusCode int) { 31 | rw.statusCode = statusCode 32 | rw.ResponseWriter.WriteHeader(statusCode) 33 | } 34 | 35 | // Status returns the status code 36 | func (rw *ResponseWriter) Status() int { 37 | return rw.statusCode 38 | } 39 | 40 | // LoggingConfig holds configuration for the request logging middleware 41 | type LoggingConfig struct { 42 | // Whether to log request body (disabled by default for privacy and size reasons) 43 | LogRequestBody bool 44 | // Whether to log response body (disabled by default for privacy and size reasons) 45 | LogResponseBody bool 46 | // Maximum size of request/response body to log 47 | MaxBodyLogSize int 48 | // Skip logging for specified paths 49 | SkipPaths []string 50 | } 51 | 52 | // DefaultLoggingConfig returns the default logging configuration 53 | func DefaultLoggingConfig() *LoggingConfig { 54 | return &LoggingConfig{ 55 | LogRequestBody: false, 56 | LogResponseBody: false, 57 | MaxBodyLogSize: 1024, // 1 KB 58 | SkipPaths: []string{"/ping", "/health"}, 59 | } 60 | } 61 | 62 | // RequestLogger returns a middleware that logs incoming requests and outgoing responses 63 | func RequestLogger() gin.HandlerFunc { 64 | return RequestLoggerWithConfig(DefaultLoggingConfig()) 65 | } 66 | 67 | // RequestLoggerWithConfig returns a middleware that logs requests and responses with custom config 68 | func RequestLoggerWithConfig(config *LoggingConfig) gin.HandlerFunc { 69 | if config == nil { 70 | config = DefaultLoggingConfig() 71 | } 72 | 73 | return func(c *gin.Context) { 74 | // Skip logging for certain paths 75 | for _, path := range config.SkipPaths { 76 | if c.Request.URL.Path == path { 77 | c.Next() 78 | return 79 | } 80 | } 81 | 82 | // Get request ID from context 83 | var requestID string 84 | if id, exists := c.Get(RequestIDHeader); exists { 85 | requestID = id.(string) 86 | } 87 | 88 | // Start timer 89 | start := time.Now() 90 | 91 | // Read request body if enabled 92 | var requestBody []byte 93 | if config.LogRequestBody && c.Request.Body != nil { 94 | var err error 95 | requestBody, err = io.ReadAll(c.Request.Body) 96 | if err != nil { 97 | log.Logger.Error("Failed to read request body", zap.Error(err)) 98 | } 99 | 100 | // Restore request body 101 | c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody)) 102 | } 103 | 104 | // Create a response writer wrapper to capture the response 105 | responseWriter := &ResponseWriter{ 106 | ResponseWriter: c.Writer, 107 | body: &bytes.Buffer{}, 108 | statusCode: http.StatusOK, // Default status is 200 109 | } 110 | c.Writer = responseWriter 111 | 112 | // Process request 113 | c.Next() 114 | 115 | // Calculate request duration 116 | duration := time.Since(start) 117 | 118 | // Check if request path is an API path 119 | isAPIPath := len(c.Errors) == 0 && c.Writer.Status() < 500 120 | 121 | // Determine log level based on status code 122 | var logMethod func(string, ...zap.Field) 123 | if c.Writer.Status() >= 500 { 124 | logMethod = log.Logger.Error 125 | } else if c.Writer.Status() >= 400 { 126 | logMethod = log.Logger.Warn 127 | } else { 128 | logMethod = log.Logger.Info 129 | } 130 | 131 | // Create log fields 132 | fields := []zap.Field{ 133 | zap.String("method", c.Request.Method), 134 | zap.String("path", c.Request.URL.Path), 135 | zap.String("query", c.Request.URL.RawQuery), 136 | zap.String("ip", c.ClientIP()), 137 | zap.String("user-agent", c.Request.UserAgent()), 138 | zap.Int("status", responseWriter.Status()), 139 | zap.String("latency", duration.String()), 140 | zap.Int64("latency_ms", duration.Milliseconds()), 141 | zap.String("request_id", requestID), 142 | } 143 | 144 | // Add request body if enabled and present 145 | if config.LogRequestBody && len(requestBody) > 0 { 146 | // Limit the size of the logged body 147 | if len(requestBody) > config.MaxBodyLogSize { 148 | requestBody = requestBody[:config.MaxBodyLogSize] 149 | } 150 | fields = append(fields, zap.ByteString("request_body", requestBody)) 151 | } 152 | 153 | // Add response body if enabled 154 | if config.LogResponseBody && responseWriter.body.Len() > 0 { 155 | responseBody := responseWriter.body.Bytes() 156 | // Limit the size of the logged body 157 | if len(responseBody) > config.MaxBodyLogSize { 158 | responseBody = responseBody[:config.MaxBodyLogSize] 159 | } 160 | fields = append(fields, zap.ByteString("response_body", responseBody)) 161 | } 162 | 163 | // Add error if present 164 | if len(c.Errors) > 0 { 165 | fields = append(fields, zap.String("errors", c.Errors.String())) 166 | } 167 | 168 | // Log the request 169 | message := "Request" 170 | if isAPIPath { 171 | message = "API Request" 172 | } 173 | logMethod(message, fields...) 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /api/http/middleware/translations.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "github.com/gin-gonic/gin/binding" 6 | "github.com/go-playground/locales/en" 7 | "github.com/go-playground/locales/zh" 8 | ut "github.com/go-playground/universal-translator" 9 | "github.com/go-playground/validator/v10" 10 | en_translations "github.com/go-playground/validator/v10/translations/en" 11 | zh_translations "github.com/go-playground/validator/v10/translations/zh" 12 | ) 13 | 14 | // Translations returns a middleware that handles translations for validation errors 15 | func Translations() gin.HandlerFunc { 16 | return func(c *gin.Context) { 17 | // Initialize translators 18 | uni := ut.New(en.New(), zh.New()) 19 | 20 | // Get locale from header or default to en 21 | locale := c.GetHeader("Accept-Language") 22 | if locale == "" { 23 | locale = "en" 24 | } 25 | 26 | // Get translator for the locale 27 | trans, _ := uni.GetTranslator(locale) 28 | 29 | // Register translator with validator 30 | if v, ok := binding.Validator.Engine().(*validator.Validate); ok { 31 | switch locale { 32 | case "zh": 33 | zh_translations.RegisterDefaultTranslations(v, trans) 34 | default: 35 | en_translations.RegisterDefaultTranslations(v, trans) 36 | } 37 | 38 | // Store translator in context 39 | c.Set("trans", trans) 40 | } 41 | 42 | c.Next() 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /api/http/paginate/paginate.go: -------------------------------------------------------------------------------- 1 | package paginate 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "github.com/spf13/cast" 6 | 7 | "go-hexagonal/config" 8 | ) 9 | 10 | func GetPage(c *gin.Context) int { 11 | page := cast.ToInt(c.Query("page")) 12 | if page <= 0 { 13 | return 1 14 | } 15 | 16 | return page 17 | } 18 | 19 | func GetPageSize(c *gin.Context) int { 20 | pageSize := cast.ToInt(c.Query("page_size")) 21 | if pageSize <= 0 { 22 | return config.GlobalConfig.HTTPServer.DefaultPageSize 23 | } 24 | if pageSize > config.GlobalConfig.HTTPServer.MaxPageSize { 25 | return config.GlobalConfig.HTTPServer.MaxPageSize 26 | } 27 | 28 | return pageSize 29 | } 30 | 31 | func GetPageOffset(page, pageSize int) int { 32 | return (page - 1) * pageSize 33 | } 34 | -------------------------------------------------------------------------------- /api/http/router.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "net/http" 5 | "time" 6 | 7 | "github.com/gin-contrib/cors" 8 | "github.com/gin-gonic/gin" 9 | "github.com/gin-gonic/gin/binding" 10 | "github.com/go-playground/validator/v10" 11 | 12 | httpMiddleware "go-hexagonal/api/http/middleware" 13 | "go-hexagonal/api/http/validator/custom" 14 | metricsMiddleware "go-hexagonal/api/middleware" 15 | "go-hexagonal/application" 16 | "go-hexagonal/config" 17 | "go-hexagonal/domain/repo" 18 | "go-hexagonal/domain/service" 19 | ) 20 | 21 | // Service instances for API handlers 22 | var ( 23 | services *service.Services 24 | converter service.Converter 25 | ) 26 | 27 | // RegisterServices registers service instances for API handlers 28 | func RegisterServices(s *service.Services) { 29 | services = s 30 | } 31 | 32 | // RegisterConverter registers a converter instance for API handlers 33 | // This is mainly used for testing 34 | func RegisterConverter(c service.Converter) { 35 | converter = c 36 | } 37 | 38 | // InitAppFactory initializes the application factory and sets it for API handlers 39 | func InitAppFactory(s *service.Services) { 40 | // Create transaction factory instance 41 | txFactory := repo.NewNoOpTransactionFactory() 42 | 43 | // Create application factory with necessary parameters 44 | factory := application.NewFactory( 45 | s.ExampleService, 46 | txFactory, 47 | ) 48 | 49 | // Use the external SetAppFactory function defined in example.go 50 | SetAppFactory(factory) 51 | } 52 | 53 | // NewServerRoute creates and configures the HTTP server routes 54 | func NewServerRoute() *gin.Engine { 55 | if config.GlobalConfig.Env.IsProd() { 56 | gin.SetMode(gin.ReleaseMode) 57 | } 58 | 59 | router := gin.New() 60 | 61 | // Register custom validators 62 | if v, ok := binding.Validator.Engine().(*validator.Validate); ok { 63 | custom.RegisterValidators(v) 64 | } 65 | 66 | // Apply middleware 67 | router.Use(gin.Recovery()) 68 | router.Use(httpMiddleware.RequestID()) // Add request ID middleware 69 | router.Use(httpMiddleware.Cors()) 70 | router.Use(httpMiddleware.RequestLogger()) // Add request logging middleware 71 | router.Use(httpMiddleware.Translations()) 72 | router.Use(httpMiddleware.ErrorHandlerMiddleware()) // Add unified error handling middleware 73 | 74 | // Add metrics middleware for each handler 75 | router.Use(func(c *gin.Context) { 76 | // Use the path as a label for the metrics 77 | handlerName := c.FullPath() 78 | if handlerName == "" { 79 | handlerName = "unknown" 80 | } 81 | 82 | // Record the start time 83 | start := time.Now() 84 | 85 | // Process the request 86 | c.Next() 87 | 88 | // Record metrics after request is processed 89 | duration := time.Since(start) 90 | statusCode := c.Writer.Status() 91 | 92 | // Record request metrics 93 | metricsMiddleware.RecordHTTPMetrics(handlerName, c.Request.Method, statusCode, duration) 94 | }) 95 | 96 | // Health check 97 | router.GET("/ping", func(c *gin.Context) { 98 | c.String(http.StatusOK, "pong") 99 | }) 100 | 101 | // Debug tools 102 | if config.GlobalConfig.HTTPServer.Pprof { 103 | httpMiddleware.RegisterPprof(router) 104 | } 105 | 106 | // Configure CORS 107 | router.Use(cors.New(cors.Config{ 108 | AllowOrigins: []string{"*"}, 109 | AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}, 110 | AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization"}, 111 | ExposeHeaders: []string{"Content-Length"}, 112 | AllowCredentials: true, 113 | MaxAge: 12 * time.Hour, 114 | })) 115 | 116 | // Unified API version 117 | api := router.Group("/api") 118 | { 119 | // Example API 120 | examples := api.Group("/examples") 121 | { 122 | examples.POST("", CreateExample) 123 | examples.GET("/:id", GetExample) 124 | examples.PUT("/:id", UpdateExample) 125 | examples.DELETE("/:id", DeleteExample) 126 | examples.GET("/name/:name", FindExampleByName) 127 | } 128 | } 129 | 130 | return router 131 | } 132 | -------------------------------------------------------------------------------- /api/http/validator/custom/validator.go: -------------------------------------------------------------------------------- 1 | package custom 2 | 3 | import ( 4 | "regexp" 5 | 6 | "github.com/go-playground/validator/v10" 7 | ) 8 | 9 | // Min password length constant 10 | const ( 11 | // MinPasswordLength defines the minimum length for password validation 12 | MinPasswordLength = 8 13 | ) 14 | 15 | // RegisterValidators registers custom validators 16 | func RegisterValidators(v *validator.Validate) { 17 | // Register phone number validator 18 | _ = v.RegisterValidation("phone", validatePhone) 19 | 20 | // Register username validator 21 | _ = v.RegisterValidation("username", validateUsername) 22 | 23 | // Register password validator 24 | _ = v.RegisterValidation("password", validatePassword) 25 | } 26 | 27 | // validatePhone validates phone numbers 28 | func validatePhone(fl validator.FieldLevel) bool { 29 | phone := fl.Field().String() 30 | // Phone number validation 31 | // Matches: +1234567890, 1234567890, +86-1234567890, etc. 32 | match, _ := regexp.MatchString(`^(\+\d{1,3}[-]?)?\d{10}$`, phone) 33 | return match 34 | } 35 | 36 | // validateUsername validates usernames 37 | func validateUsername(fl validator.FieldLevel) bool { 38 | username := fl.Field().String() 39 | // Username must be 3-20 characters long and contain only letters, numbers, and underscores 40 | match, _ := regexp.MatchString(`^[a-zA-Z0-9_]{3,20}$`, username) 41 | return match 42 | } 43 | 44 | // validatePassword validates passwords 45 | func validatePassword(fl validator.FieldLevel) bool { 46 | password := fl.Field().String() 47 | // Password must be at least 8 characters long and contain: 48 | // - At least one uppercase letter 49 | // - At least one lowercase letter 50 | // - At least one number 51 | // - At least one special character 52 | hasUpper := regexp.MustCompile(`[A-Z]`).MatchString(password) 53 | hasLower := regexp.MustCompile(`[a-z]`).MatchString(password) 54 | hasNumber := regexp.MustCompile(`[0-9]`).MatchString(password) 55 | hasSpecial := regexp.MustCompile(`[!@#$%^&*]`).MatchString(password) 56 | hasLength := len(password) >= MinPasswordLength 57 | return hasUpper && hasLower && hasNumber && hasSpecial && hasLength 58 | } 59 | -------------------------------------------------------------------------------- /api/http/validator/custom/validator_test.go: -------------------------------------------------------------------------------- 1 | package custom 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/go-playground/validator/v10" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestValidatePhone(t *testing.T) { 11 | validate := validator.New() 12 | err := validate.RegisterValidation("phone", validatePhone) 13 | assert.NoError(t, err) 14 | 15 | type TestStruct struct { 16 | Phone string `validate:"phone"` 17 | } 18 | 19 | tests := []struct { 20 | name string 21 | phone string 22 | wantErr bool 23 | }{ 24 | { 25 | name: "Valid phone without country code", 26 | phone: "1234567890", 27 | wantErr: false, 28 | }, 29 | { 30 | name: "Valid phone with country code", 31 | phone: "+861234567890", 32 | wantErr: false, 33 | }, 34 | { 35 | name: "Valid phone with country code and hyphen", 36 | phone: "+86-1234567890", 37 | wantErr: false, 38 | }, 39 | { 40 | name: "Invalid phone - too short", 41 | phone: "123456", 42 | wantErr: true, 43 | }, 44 | { 45 | name: "Invalid phone - contains letters", 46 | phone: "123abc4567", 47 | wantErr: true, 48 | }, 49 | { 50 | name: "Invalid phone - wrong format", 51 | phone: "++1234567890", 52 | wantErr: true, 53 | }, 54 | } 55 | 56 | for _, tt := range tests { 57 | t.Run(tt.name, func(t *testing.T) { 58 | test := TestStruct{ 59 | Phone: tt.phone, 60 | } 61 | err := validate.Struct(test) 62 | if tt.wantErr { 63 | assert.Error(t, err) 64 | } else { 65 | assert.NoError(t, err) 66 | } 67 | }) 68 | } 69 | } 70 | 71 | func TestValidateUsername(t *testing.T) { 72 | validate := validator.New() 73 | _ = validate.RegisterValidation("username", validateUsername) 74 | 75 | type TestStruct struct { 76 | Username string `validate:"username"` 77 | } 78 | 79 | tests := []struct { 80 | name string 81 | username string 82 | wantErr bool 83 | }{ 84 | {"Valid username", "john_doe123", false}, 85 | {"Valid username - minimum length", "abc", false}, 86 | {"Invalid username - too short", "ab", true}, 87 | {"Invalid username - too long", "abcdefghijklmnopqrstu", true}, 88 | {"Invalid username - special chars", "john@doe", true}, 89 | {"Invalid username - empty", "", true}, 90 | } 91 | 92 | for _, tt := range tests { 93 | t.Run(tt.name, func(t *testing.T) { 94 | test := TestStruct{Username: tt.username} 95 | err := validate.Struct(test) 96 | if tt.wantErr { 97 | assert.Error(t, err) 98 | } else { 99 | assert.NoError(t, err) 100 | } 101 | }) 102 | } 103 | } 104 | 105 | func TestValidatePassword(t *testing.T) { 106 | validate := validator.New() 107 | _ = validate.RegisterValidation("password", validatePassword) 108 | 109 | type TestStruct struct { 110 | Password string `validate:"password"` 111 | } 112 | 113 | tests := []struct { 114 | name string 115 | password string 116 | wantErr bool 117 | }{ 118 | {"Valid password", "Test123!", false}, 119 | {"Valid password - complex", "P@ssw0rd!", false}, 120 | {"Invalid password - no uppercase", "test123!", true}, 121 | {"Invalid password - no lowercase", "TEST123!", true}, 122 | {"Invalid password - no number", "TestTest!", true}, 123 | {"Invalid password - no special char", "Test1234", true}, 124 | {"Invalid password - too short", "Te1!", true}, 125 | {"Invalid password - empty", "", true}, 126 | } 127 | 128 | for _, tt := range tests { 129 | t.Run(tt.name, func(t *testing.T) { 130 | test := TestStruct{Password: tt.password} 131 | err := validate.Struct(test) 132 | if tt.wantErr { 133 | assert.Error(t, err) 134 | } else { 135 | assert.NoError(t, err) 136 | } 137 | }) 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /api/http/validator/validator.go: -------------------------------------------------------------------------------- 1 | package validator 2 | 3 | import ( 4 | "reflect" 5 | "strings" 6 | 7 | "github.com/fatih/structtag" 8 | "github.com/gin-gonic/gin" 9 | ut "github.com/go-playground/universal-translator" 10 | "github.com/go-playground/validator/v10" 11 | ) 12 | 13 | const MessageTagKey = "message" 14 | 15 | type ValidError struct { 16 | Key string 17 | Message string 18 | } 19 | 20 | type ValidErrors []*ValidError 21 | 22 | func (v *ValidError) Error() string { 23 | return v.Message 24 | } 25 | 26 | func (v *ValidErrors) Errors() []string { 27 | errs := make([]string, 0) 28 | for _, err := range *v { 29 | errs = append(errs, err.Error()) 30 | } 31 | return errs 32 | } 33 | 34 | func (v *ValidErrors) Error() string { 35 | return strings.Join(v.Errors(), ",") 36 | } 37 | 38 | func BindAndValid(c *gin.Context, obj any, binder func(any) error) (bool, ValidErrors) { 39 | var errs ValidErrors 40 | err := binder(obj) 41 | if err != nil { 42 | v := c.Value("trans") 43 | trans, _ := v.(ut.Translator) 44 | verrs, ok := err.(validator.ValidationErrors) 45 | if !ok { 46 | errs = append(errs, &ValidError{ 47 | Key: "unknown error", 48 | Message: err.Error(), 49 | }) 50 | 51 | return false, errs 52 | } 53 | 54 | for key, value := range verrs.Translate(trans) { 55 | validError := &ValidError{ 56 | Key: key, 57 | Message: value, 58 | } 59 | 60 | // get message tag, and replace valid Error.Message with message from tag 61 | tmpKey := strings.Split(key, ".") 62 | fieldName := tmpKey[len(tmpKey)-1] 63 | t := reflect.TypeOf(obj) 64 | k := t.Kind() 65 | for k == reflect.Ptr { 66 | t = t.Elem() 67 | k = t.Kind() 68 | } 69 | field, exists := t.FieldByName(fieldName) 70 | var tag reflect.StructTag 71 | if exists { 72 | tag = field.Tag 73 | } 74 | if tag != "" { 75 | tags, _ := structtag.Parse(string(tag)) 76 | messageTag, _ := tags.Get(MessageTagKey) 77 | if messageTag != nil && messageTag.Name != "" { 78 | validError.Message = messageTag.Name 79 | } 80 | } 81 | 82 | errs = append(errs, validError) 83 | } 84 | return false, errs 85 | } 86 | 87 | return true, nil 88 | } 89 | -------------------------------------------------------------------------------- /api/middleware/metrics.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "time" 8 | 9 | "go-hexagonal/util/log" 10 | "go-hexagonal/util/metrics" 11 | ) 12 | 13 | // MetricsResponseWriter is a wrapper around http.ResponseWriter that tracks the status code 14 | type MetricsResponseWriter struct { 15 | http.ResponseWriter 16 | statusCode int 17 | } 18 | 19 | // NewMetricsResponseWriter creates a new MetricsResponseWriter 20 | func NewMetricsResponseWriter(w http.ResponseWriter) *MetricsResponseWriter { 21 | return &MetricsResponseWriter{ 22 | ResponseWriter: w, 23 | statusCode: http.StatusOK, // Default status code 24 | } 25 | } 26 | 27 | // WriteHeader captures the status code 28 | func (w *MetricsResponseWriter) WriteHeader(statusCode int) { 29 | w.statusCode = statusCode 30 | w.ResponseWriter.WriteHeader(statusCode) 31 | } 32 | 33 | // Status returns the response status code 34 | func (w *MetricsResponseWriter) Status() int { 35 | return w.statusCode 36 | } 37 | 38 | // Implements http.Hijacker if the underlying ResponseWriter does 39 | func (w *MetricsResponseWriter) Hijack() (interface{}, interface{}, error) { 40 | if hj, ok := w.ResponseWriter.(http.Hijacker); ok { 41 | return hj.Hijack() 42 | } 43 | return nil, nil, fmt.Errorf("underlying ResponseWriter does not implement http.Hijacker") 44 | } 45 | 46 | // Flush implements http.Flusher if the underlying ResponseWriter does 47 | func (w *MetricsResponseWriter) Flush() { 48 | if f, ok := w.ResponseWriter.(http.Flusher); ok { 49 | f.Flush() 50 | } 51 | } 52 | 53 | // CloseNotify implements http.CloseNotifier if the underlying ResponseWriter does 54 | func (w *MetricsResponseWriter) CloseNotify() <-chan bool { 55 | if cn, ok := w.ResponseWriter.(http.CloseNotifier); ok { 56 | return cn.CloseNotify() 57 | } 58 | return make(<-chan bool) 59 | } 60 | 61 | // RecordHTTPMetrics records HTTP metrics for a request 62 | func RecordHTTPMetrics(handlerName, method string, statusCode int, duration time.Duration) { 63 | if !metrics.Initialized() { 64 | return 65 | } 66 | 67 | // Convert status code to string 68 | statusCodeStr := fmt.Sprintf("%d", statusCode) 69 | 70 | // Record request duration and count 71 | metrics.RequestDuration.WithLabelValues(handlerName, method, statusCodeStr).Observe(duration.Seconds()) 72 | metrics.RequestTotal.WithLabelValues(handlerName, method, statusCodeStr).Inc() 73 | 74 | // Record errors if any 75 | if statusCode >= 400 { 76 | errorType := "client_error" 77 | if statusCode >= 500 { 78 | errorType = "server_error" 79 | } 80 | metrics.RecordError(errorType, handlerName) 81 | log.SugaredLogger.Debugf("HTTP %s error for %s %s (handler: %s): %d", 82 | errorType, method, handlerName, handlerName, statusCode) 83 | } 84 | } 85 | 86 | // MetricsMiddleware creates a middleware for collecting HTTP metrics 87 | func MetricsMiddleware(handlerName string) func(http.Handler) http.Handler { 88 | return func(next http.Handler) http.Handler { 89 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 90 | if !metrics.Initialized() { 91 | next.ServeHTTP(w, r) 92 | return 93 | } 94 | 95 | start := time.Now() 96 | metricsWriter := NewMetricsResponseWriter(w) 97 | 98 | // Process the request 99 | next.ServeHTTP(metricsWriter, r) 100 | 101 | // Record metrics after request is processed 102 | duration := time.Since(start) 103 | statusCode := metricsWriter.Status() 104 | 105 | // Use RecordHTTPMetrics to record the metrics 106 | RecordHTTPMetrics(handlerName, r.Method, statusCode, duration) 107 | }) 108 | } 109 | } 110 | 111 | // InitializeMetrics initializes the metrics collection system 112 | func InitializeMetrics() { 113 | metrics.Init() 114 | log.SugaredLogger.Info("Metrics collection system initialized") 115 | } 116 | 117 | // StartMetricsServer starts the metrics server 118 | func StartMetricsServer(addr string) error { 119 | log.SugaredLogger.Infof("Starting metrics server on %s", addr) 120 | ctx := context.Background() 121 | return metrics.StartServer(ctx, addr) 122 | } 123 | -------------------------------------------------------------------------------- /application/core/interfaces.go: -------------------------------------------------------------------------------- 1 | // Package core provides core interfaces and abstractions for the application layer 2 | package core 3 | 4 | import ( 5 | "context" 6 | 7 | "go-hexagonal/domain/repo" 8 | "go-hexagonal/util/errors" 9 | "go-hexagonal/util/log" 10 | ) 11 | 12 | // UseCase defines the interface for all use cases in the application 13 | type UseCase interface { 14 | // Execute processes the use case with the given input and returns the result or an error 15 | Execute(ctx context.Context, input any) (any, error) 16 | } 17 | 18 | // UseCaseHandler provides a base implementation for use cases 19 | type UseCaseHandler struct { 20 | TxFactory repo.TransactionFactory 21 | } 22 | 23 | // NewUseCaseHandler creates a new use case handler 24 | func NewUseCaseHandler(txFactory repo.TransactionFactory) *UseCaseHandler { 25 | return &UseCaseHandler{ 26 | TxFactory: txFactory, 27 | } 28 | } 29 | 30 | // ExecuteInTransaction executes the given function within a transaction 31 | func (h *UseCaseHandler) ExecuteInTransaction( 32 | ctx context.Context, 33 | storeType repo.StoreType, 34 | fn func(context.Context, repo.Transaction) (any, error), 35 | ) (any, error) { 36 | // Create transaction 37 | tx, err := h.TxFactory.NewTransaction(ctx, storeType, nil) 38 | if err != nil { 39 | log.SugaredLogger.Errorf("Failed to create transaction: %v", err) 40 | return nil, errors.Wrapf(err, errors.ErrorTypeSystem, "failed to create transaction") 41 | } 42 | defer tx.Rollback() 43 | 44 | // Execute function within transaction 45 | result, err := fn(ctx, tx) 46 | if err != nil { 47 | log.SugaredLogger.Errorf("Transaction execution failed: %v", err) 48 | return nil, err 49 | } 50 | 51 | // Commit transaction 52 | if err = tx.Commit(); err != nil { 53 | log.SugaredLogger.Errorf("Failed to commit transaction: %v", err) 54 | return nil, errors.Wrapf(err, errors.ErrorTypeSystem, "failed to commit transaction") 55 | } 56 | 57 | return result, nil 58 | } 59 | 60 | // Input defines the base interface for all use case inputs 61 | type Input interface { 62 | Validate() error 63 | } 64 | 65 | // BaseInput provides common input validation functionality 66 | type BaseInput struct{} 67 | 68 | // Validate performs basic validation on the input 69 | func (b *BaseInput) Validate() error { 70 | return nil 71 | } 72 | 73 | // Output defines the base interface for all use case outputs 74 | type Output interface { 75 | GetStatus() string 76 | } 77 | 78 | // BaseOutput provides common output functionality 79 | type BaseOutput struct { 80 | Status string `json:"status,omitempty"` 81 | } 82 | 83 | // GetStatus returns the status of the output 84 | func (o *BaseOutput) GetStatus() string { 85 | return o.Status 86 | } 87 | 88 | // NewSuccessOutput creates a new success output 89 | func NewSuccessOutput() *BaseOutput { 90 | return &BaseOutput{Status: "success"} 91 | } 92 | 93 | // ValidationError returns a validation error with the given message and details 94 | func ValidationError(message string, details map[string]any) error { 95 | return errors.NewValidationError(message, nil).WithDetails(details) 96 | } 97 | 98 | // NotFoundError returns a not found error with the given message 99 | func NotFoundError(message string) error { 100 | return errors.New(errors.ErrorTypeNotFound, message) 101 | } 102 | 103 | // SystemError returns a system error with the given message and cause 104 | func SystemError(message string, cause error) error { 105 | return errors.NewSystemError(message, cause) 106 | } 107 | 108 | // BusinessError returns a business error with the given message 109 | func BusinessError(message string) error { 110 | return errors.New(errors.ErrorTypeBusiness, message) 111 | } 112 | -------------------------------------------------------------------------------- /application/core/monitored.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "go-hexagonal/domain/repo" 8 | "go-hexagonal/util/errors" 9 | "go-hexagonal/util/log" 10 | "go-hexagonal/util/metrics" 11 | ) 12 | 13 | // MonitoredUseCaseHandler extends UseCaseHandler with monitoring capabilities 14 | type MonitoredUseCaseHandler struct { 15 | *UseCaseHandler 16 | useCaseName string 17 | storeType repo.StoreType 18 | } 19 | 20 | // NewMonitoredUseCaseHandler creates a new monitored use case handler 21 | func NewMonitoredUseCaseHandler(base *UseCaseHandler, storeType repo.StoreType, useCaseName string) *MonitoredUseCaseHandler { 22 | return &MonitoredUseCaseHandler{ 23 | UseCaseHandler: base, 24 | storeType: storeType, 25 | useCaseName: useCaseName, 26 | } 27 | } 28 | 29 | // ExecuteInTransaction executes the given function within a transaction with monitoring 30 | func (h *MonitoredUseCaseHandler) ExecuteInTransaction( 31 | ctx context.Context, 32 | storeType repo.StoreType, 33 | fn func(context.Context, repo.Transaction) (any, error), 34 | ) (any, error) { 35 | // If metrics is not initialized, fallback to base implementation 36 | if !metrics.Initialized() { 37 | return h.UseCaseHandler.ExecuteInTransaction(ctx, storeType, fn) 38 | } 39 | 40 | // Measure transaction execution time 41 | var result any 42 | var txErr error 43 | 44 | err := metrics.MeasureTransaction(fmt.Sprintf("usecase_%s", h.useCaseName), func() error { 45 | // Create transaction 46 | tx, err := h.TxFactory.NewTransaction(ctx, storeType, nil) 47 | if err != nil { 48 | metrics.RecordError("transaction_factory", string(storeType)) 49 | log.SugaredLogger.Errorf("Failed to create transaction: %v", err) 50 | return errors.Wrapf(err, errors.ErrorTypeSystem, "failed to create transaction") 51 | } 52 | defer tx.Rollback() 53 | 54 | // Execute function within transaction 55 | result, txErr = fn(ctx, tx) 56 | if txErr != nil { 57 | metrics.RecordError("transaction_execution", string(storeType)) 58 | log.SugaredLogger.Errorf("Transaction execution failed: %v", txErr) 59 | return txErr 60 | } 61 | 62 | // Commit transaction 63 | if err = tx.Commit(); err != nil { 64 | metrics.RecordError("transaction_commit", string(storeType)) 65 | log.SugaredLogger.Errorf("Failed to commit transaction: %v", err) 66 | return errors.Wrapf(err, errors.ErrorTypeSystem, "failed to commit transaction") 67 | } 68 | 69 | return nil 70 | }) 71 | 72 | if err != nil { 73 | metrics.RecordError("usecase", h.useCaseName) 74 | return nil, err 75 | } 76 | 77 | return result, nil 78 | } 79 | 80 | // ValidateInput validates the input using the provided validator with monitoring 81 | func (h *MonitoredUseCaseHandler) ValidateInput(input Input) error { 82 | if !metrics.Initialized() { 83 | return input.Validate() 84 | } 85 | 86 | err := input.Validate() 87 | if err != nil { 88 | metrics.RecordError("validation", h.useCaseName) 89 | } 90 | return err 91 | } 92 | 93 | // HandleResultWithMetrics wraps a result with metrics monitoring 94 | func HandleResultWithMetrics(useCaseName string, result any, err error) (any, error) { 95 | if !metrics.Initialized() { 96 | return result, err 97 | } 98 | 99 | if err != nil { 100 | metrics.RecordError("usecase_result", useCaseName) 101 | } 102 | return result, err 103 | } 104 | 105 | // MonitoredUseCase wraps a use case with monitoring 106 | type MonitoredUseCase struct { 107 | useCase UseCase 108 | useCaseName string 109 | } 110 | 111 | // NewMonitoredUseCase creates a new monitored use case 112 | func NewMonitoredUseCase(useCase UseCase, useCaseName string) *MonitoredUseCase { 113 | return &MonitoredUseCase{ 114 | useCase: useCase, 115 | useCaseName: useCaseName, 116 | } 117 | } 118 | 119 | // Execute executes the use case with monitoring 120 | func (u *MonitoredUseCase) Execute(ctx context.Context, input any) (any, error) { 121 | if !metrics.Initialized() { 122 | return u.useCase.Execute(ctx, input) 123 | } 124 | 125 | var result any 126 | var execErr error 127 | 128 | err := metrics.MeasureTransaction(fmt.Sprintf("usecase_%s", u.useCaseName), func() error { 129 | result, execErr = u.useCase.Execute(ctx, input) 130 | return execErr 131 | }) 132 | 133 | if err != nil { 134 | metrics.RecordError("usecase", u.useCaseName) 135 | } 136 | 137 | return result, execErr 138 | } 139 | -------------------------------------------------------------------------------- /application/example/create.go: -------------------------------------------------------------------------------- 1 | package example 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "go-hexagonal/application/core" 8 | "go-hexagonal/domain/repo" 9 | "go-hexagonal/domain/service" 10 | "go-hexagonal/util/log" 11 | ) 12 | 13 | // CreateUseCase handles the create example use case 14 | type CreateUseCase struct { 15 | *core.UseCaseHandler 16 | exampleService service.IExampleService 17 | } 18 | 19 | // NewCreateUseCase creates a new CreateUseCase instance 20 | func NewCreateUseCase( 21 | exampleService service.IExampleService, 22 | txFactory repo.TransactionFactory, 23 | ) *CreateUseCase { 24 | return &CreateUseCase{ 25 | UseCaseHandler: core.NewUseCaseHandler(txFactory), 26 | exampleService: exampleService, 27 | } 28 | } 29 | 30 | // Execute processes the create example request 31 | func (uc *CreateUseCase) Execute(ctx context.Context, input any) (any, error) { 32 | // Convert and validate input 33 | createInput, ok := input.(*CreateInput) 34 | if !ok { 35 | return nil, core.ValidationError("invalid input type", nil) 36 | } 37 | 38 | if err := createInput.Validate(); err != nil { 39 | return nil, err 40 | } 41 | 42 | // Execute in transaction 43 | result, err := uc.ExecuteInTransaction(ctx, repo.MySQLStore, func(ctx context.Context, tx repo.Transaction) (any, error) { 44 | // Call domain service 45 | example, err := uc.exampleService.Create(ctx, createInput.Name, createInput.Alias) 46 | if err != nil { 47 | log.SugaredLogger.Errorf("Failed to create example: %v", err) 48 | return nil, fmt.Errorf("failed to create example: %w", err) 49 | } 50 | 51 | // Create output DTO 52 | return NewExampleOutput(example), nil 53 | }) 54 | 55 | if err != nil { 56 | return nil, err 57 | } 58 | 59 | return result, nil 60 | } 61 | -------------------------------------------------------------------------------- /application/example/delete.go: -------------------------------------------------------------------------------- 1 | package example 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "go-hexagonal/application/core" 8 | "go-hexagonal/domain/repo" 9 | "go-hexagonal/domain/service" 10 | "go-hexagonal/util/log" 11 | ) 12 | 13 | // DeleteUseCase handles the delete example use case 14 | type DeleteUseCase struct { 15 | *core.UseCaseHandler 16 | exampleService service.IExampleService 17 | } 18 | 19 | // NewDeleteUseCase creates a new DeleteUseCase instance 20 | func NewDeleteUseCase( 21 | exampleService service.IExampleService, 22 | txFactory repo.TransactionFactory, 23 | ) *DeleteUseCase { 24 | return &DeleteUseCase{ 25 | UseCaseHandler: core.NewUseCaseHandler(txFactory), 26 | exampleService: exampleService, 27 | } 28 | } 29 | 30 | // Execute processes the delete example request 31 | func (uc *DeleteUseCase) Execute(ctx context.Context, input any) (any, error) { 32 | // Convert and validate input 33 | deleteInput, ok := input.(*DeleteInput) 34 | if !ok { 35 | return nil, core.ValidationError("invalid input type", nil) 36 | } 37 | 38 | if err := deleteInput.Validate(); err != nil { 39 | return nil, err 40 | } 41 | 42 | // Execute in transaction 43 | _, err := uc.ExecuteInTransaction(ctx, repo.MySQLStore, func(ctx context.Context, tx repo.Transaction) (any, error) { 44 | // Call domain service to delete the example 45 | err := uc.exampleService.Delete(ctx, deleteInput.ID) 46 | if err != nil { 47 | log.SugaredLogger.Errorf("Failed to delete example: %v", err) 48 | return nil, fmt.Errorf("failed to delete example: %w", err) 49 | } 50 | 51 | return core.NewSuccessOutput(), nil 52 | }) 53 | 54 | if err != nil { 55 | return nil, err 56 | } 57 | 58 | return core.NewSuccessOutput(), nil 59 | } 60 | -------------------------------------------------------------------------------- /application/example/delete_test.go: -------------------------------------------------------------------------------- 1 | package example 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/mock" 10 | 11 | "go-hexagonal/domain/repo" 12 | ) 13 | 14 | // testableDeleteUseCase is a testable implementation of the delete use case that replaces actual transaction handling 15 | type testableDeleteUseCase struct { 16 | DeleteUseCase 17 | txProvider func(ctx context.Context) (repo.Transaction, error) 18 | } 19 | 20 | // newTestableDeleteUseCase creates a testable delete use case 21 | func newTestableDeleteUseCase(svc *MockExampleService) *testableDeleteUseCase { 22 | return &testableDeleteUseCase{ 23 | DeleteUseCase: DeleteUseCase{ 24 | exampleService: svc, 25 | }, 26 | txProvider: CreateTestTransaction, 27 | } 28 | } 29 | 30 | // Execute overrides the Execute method to replace transaction handling logic 31 | func (uc *testableDeleteUseCase) Execute(ctx context.Context, id int) error { 32 | // Use mock transaction 33 | tx, err := uc.txProvider(ctx) 34 | if err != nil { 35 | return fmt.Errorf("failed to create transaction: %w", err) 36 | } 37 | defer tx.Rollback() 38 | 39 | // Call domain service 40 | if err := uc.exampleService.Delete(ctx, id); err != nil { 41 | return fmt.Errorf("failed to delete example: %w", err) 42 | } 43 | 44 | // Commit transaction 45 | if err = tx.Commit(); err != nil { 46 | return fmt.Errorf("failed to commit transaction: %w", err) 47 | } 48 | 49 | return nil 50 | } 51 | 52 | // TestDeleteUseCase_Execute_Success tests the successful case of deleting an example 53 | func TestDeleteUseCase_Execute_Success(t *testing.T) { 54 | // Create mock service 55 | mockService := new(MockExampleService) 56 | 57 | // Setup mock behavior 58 | mockService.On("Delete", mock.Anything, 1).Return(nil) 59 | 60 | // Create testable use case 61 | useCase := newTestableDeleteUseCase(mockService) 62 | 63 | // Test data 64 | ctx := context.Background() 65 | exampleId := 1 66 | 67 | // Execute use case 68 | err := useCase.Execute(ctx, exampleId) 69 | 70 | // Verify results 71 | assert.NoError(t, err) 72 | mockService.AssertExpectations(t) 73 | } 74 | 75 | // TestDeleteUseCase_Execute_Error tests the error case when deleting an example 76 | func TestDeleteUseCase_Execute_Error(t *testing.T) { 77 | // Create mock service 78 | mockService := new(MockExampleService) 79 | 80 | // Setup mock behavior - simulate error 81 | expectedError := assert.AnError 82 | mockService.On("Delete", mock.Anything, 999).Return(expectedError) 83 | 84 | // Create testable use case 85 | useCase := newTestableDeleteUseCase(mockService) 86 | 87 | // Test data 88 | ctx := context.Background() 89 | exampleId := 999 // Non-existent ID 90 | 91 | // Execute use case 92 | err := useCase.Execute(ctx, exampleId) 93 | 94 | // Verify results 95 | assert.Error(t, err) 96 | assert.Contains(t, err.Error(), "failed to delete example") 97 | mockService.AssertExpectations(t) 98 | } 99 | -------------------------------------------------------------------------------- /application/example/dto.go: -------------------------------------------------------------------------------- 1 | package example 2 | 3 | import ( 4 | "time" 5 | 6 | "go-hexagonal/application/core" 7 | "go-hexagonal/domain/model" 8 | ) 9 | 10 | // Input DTOs 11 | 12 | // CreateInput represents input for creating a new example 13 | type CreateInput struct { 14 | core.BaseInput 15 | Name string `json:"name" validate:"required"` 16 | Alias string `json:"alias"` 17 | } 18 | 19 | // Validate validates the create input 20 | func (i *CreateInput) Validate() error { 21 | if i.Name == "" { 22 | return core.ValidationError("name is required", map[string]any{ 23 | "name": "required", 24 | }) 25 | } 26 | return nil 27 | } 28 | 29 | // UpdateInput represents input for updating an example 30 | type UpdateInput struct { 31 | core.BaseInput 32 | ID int `json:"id" validate:"required"` 33 | Name string `json:"name" validate:"required"` 34 | Alias string `json:"alias"` 35 | } 36 | 37 | // Validate validates the update input 38 | func (i *UpdateInput) Validate() error { 39 | if i.ID <= 0 { 40 | return core.ValidationError("invalid ID", map[string]any{ 41 | "id": "must be positive", 42 | }) 43 | } 44 | if i.Name == "" { 45 | return core.ValidationError("name is required", map[string]any{ 46 | "name": "required", 47 | }) 48 | } 49 | return nil 50 | } 51 | 52 | // GetInput represents input for retrieving an example by ID 53 | type GetInput struct { 54 | core.BaseInput 55 | ID int `json:"id" validate:"required"` 56 | } 57 | 58 | // Validate validates the get input 59 | func (i *GetInput) Validate() error { 60 | if i.ID <= 0 { 61 | return core.ValidationError("invalid ID", map[string]any{ 62 | "id": "must be positive", 63 | }) 64 | } 65 | return nil 66 | } 67 | 68 | // DeleteInput represents input for deleting an example 69 | type DeleteInput struct { 70 | core.BaseInput 71 | ID int `json:"id" validate:"required"` 72 | } 73 | 74 | // Validate validates the delete input 75 | func (i *DeleteInput) Validate() error { 76 | if i.ID <= 0 { 77 | return core.ValidationError("invalid ID", map[string]any{ 78 | "id": "must be positive", 79 | }) 80 | } 81 | return nil 82 | } 83 | 84 | // FindByNameInput represents input for finding an example by name 85 | type FindByNameInput struct { 86 | core.BaseInput 87 | Name string `json:"name" validate:"required"` 88 | } 89 | 90 | // Validate validates the find by name input 91 | func (i *FindByNameInput) Validate() error { 92 | if i.Name == "" { 93 | return core.ValidationError("name is required", map[string]any{ 94 | "name": "required", 95 | }) 96 | } 97 | return nil 98 | } 99 | 100 | // Output DTOs 101 | 102 | // ExampleOutput represents the output format for example entities 103 | type ExampleOutput struct { 104 | core.BaseOutput 105 | ID int `json:"id"` 106 | Name string `json:"name"` 107 | Alias string `json:"alias"` 108 | CreatedAt time.Time `json:"created_at"` 109 | UpdatedAt time.Time `json:"updated_at"` 110 | } 111 | 112 | // FromModel converts a domain model to an output DTO 113 | func (o *ExampleOutput) FromModel(example *model.Example) { 114 | o.ID = example.Id 115 | o.Name = example.Name 116 | o.Alias = example.Alias 117 | o.CreatedAt = example.CreatedAt 118 | o.UpdatedAt = example.UpdatedAt 119 | o.Status = "success" 120 | } 121 | 122 | // NewExampleOutput creates a new example output from a model 123 | func NewExampleOutput(example *model.Example) *ExampleOutput { 124 | output := &ExampleOutput{} 125 | output.FromModel(example) 126 | return output 127 | } 128 | -------------------------------------------------------------------------------- /application/example/find_by_name.go: -------------------------------------------------------------------------------- 1 | package example 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "go-hexagonal/application/core" 8 | "go-hexagonal/domain/repo" 9 | "go-hexagonal/domain/service" 10 | "go-hexagonal/util/log" 11 | ) 12 | 13 | // FindByNameUseCase handles the find example by name use case 14 | type FindByNameUseCase struct { 15 | *core.UseCaseHandler 16 | exampleService service.IExampleService 17 | } 18 | 19 | // NewFindByNameUseCase creates a new FindByNameUseCase instance 20 | func NewFindByNameUseCase( 21 | exampleService service.IExampleService, 22 | txFactory repo.TransactionFactory, 23 | ) *FindByNameUseCase { 24 | return &FindByNameUseCase{ 25 | UseCaseHandler: core.NewUseCaseHandler(txFactory), 26 | exampleService: exampleService, 27 | } 28 | } 29 | 30 | // Execute processes the find example by name request 31 | func (uc *FindByNameUseCase) Execute(ctx context.Context, input any) (any, error) { 32 | // Convert and validate input 33 | findInput, ok := input.(*FindByNameInput) 34 | if !ok { 35 | return nil, core.ValidationError("invalid input type", nil) 36 | } 37 | 38 | if err := findInput.Validate(); err != nil { 39 | return nil, err 40 | } 41 | 42 | // Find example by name (no transaction needed for read-only operation) 43 | example, err := uc.exampleService.FindByName(ctx, findInput.Name) 44 | if err != nil { 45 | log.SugaredLogger.Errorf("Failed to find example by name: %v", err) 46 | return nil, fmt.Errorf("failed to find example by name: %w", err) 47 | } 48 | 49 | if example == nil { 50 | return nil, core.NotFoundError(fmt.Sprintf("example with name '%s' not found", findInput.Name)) 51 | } 52 | 53 | // Create output DTO 54 | return NewExampleOutput(example), nil 55 | } 56 | -------------------------------------------------------------------------------- /application/example/find_example_by_name_test.go: -------------------------------------------------------------------------------- 1 | package example 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | "testing" 8 | "time" 9 | 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/mock" 12 | 13 | "go-hexagonal/api/dto" 14 | "go-hexagonal/domain/model" 15 | "go-hexagonal/domain/repo" 16 | ) 17 | 18 | // TestableFindByNameUseCase is a testable implementation of the query use case that replaces actual transaction handling 19 | type TestableFindByNameUseCase struct { 20 | FindByNameUseCase 21 | txProvider func(ctx context.Context) (repo.Transaction, error) 22 | } 23 | 24 | // NewTestableFindByNameUseCase creates a testable query use case 25 | func NewTestableFindByNameUseCase(svc *MockExampleService) *TestableFindByNameUseCase { 26 | return &TestableFindByNameUseCase{ 27 | FindByNameUseCase: FindByNameUseCase{ 28 | exampleService: svc, 29 | }, 30 | txProvider: CreateTestTransaction, 31 | } 32 | } 33 | 34 | // Execute overrides the method to replace transaction handling logic 35 | func (uc *TestableFindByNameUseCase) Execute(ctx context.Context, name string) (*dto.GetExampleResponse, error) { 36 | // Use mock transaction 37 | tx, err := uc.txProvider(ctx) 38 | if err != nil { 39 | return nil, fmt.Errorf("failed to create transaction: %w", err) 40 | } 41 | defer tx.Rollback() 42 | 43 | // Call domain service 44 | example, err := uc.exampleService.FindByName(ctx, name) 45 | if err != nil { 46 | if strings.Contains(err.Error(), "record not found") { 47 | return nil, fmt.Errorf("record not found") 48 | } 49 | return nil, fmt.Errorf("failed to find example: %w", err) 50 | } 51 | 52 | // Commit transaction 53 | if err = tx.Commit(); err != nil { 54 | return nil, fmt.Errorf("failed to commit transaction: %w", err) 55 | } 56 | 57 | // Convert domain model to DTO 58 | result := &dto.GetExampleResponse{ 59 | Id: example.Id, 60 | Name: example.Name, 61 | Alias: example.Alias, 62 | CreatedAt: example.CreatedAt, 63 | UpdatedAt: example.UpdatedAt, 64 | } 65 | 66 | return result, nil 67 | } 68 | 69 | // TestFindByNameUseCase_Success tests the successful case of finding an example by name 70 | func TestFindByNameUseCase_Success(t *testing.T) { 71 | // Create mock service 72 | mockService := new(MockExampleService) 73 | 74 | // Test data 75 | exampleName := "Test Example" 76 | 77 | now := time.Now() 78 | expectedExample := &model.Example{ 79 | Id: 1, 80 | Name: exampleName, 81 | Alias: "test", 82 | CreatedAt: now, 83 | UpdatedAt: now, 84 | } 85 | 86 | // Setup mock behavior 87 | mockService.On("FindByName", mock.Anything, exampleName).Return(expectedExample, nil) 88 | 89 | // Create use case with testable version 90 | useCase := NewTestableFindByNameUseCase(mockService) 91 | 92 | // Execute use case 93 | ctx := context.Background() 94 | result, err := useCase.Execute(ctx, exampleName) 95 | 96 | // Assert results 97 | assert.NoError(t, err) 98 | assert.NotNil(t, result) 99 | assert.Equal(t, expectedExample.Id, result.Id) 100 | assert.Equal(t, expectedExample.Name, result.Name) 101 | assert.Equal(t, expectedExample.Alias, result.Alias) 102 | assert.Equal(t, expectedExample.CreatedAt, result.CreatedAt) 103 | assert.Equal(t, expectedExample.UpdatedAt, result.UpdatedAt) 104 | 105 | mockService.AssertExpectations(t) 106 | } 107 | 108 | // TestFindByNameUseCase_NotFound tests the case when an example is not found 109 | func TestFindByNameUseCase_NotFound(t *testing.T) { 110 | // Create mock service 111 | mockService := new(MockExampleService) 112 | 113 | // Setup mock behavior for not found case 114 | mockService.On("FindByName", mock.Anything, "non-existent").Return(nil, repo.ErrNotFound) 115 | 116 | // Create use case with testable version 117 | useCase := NewTestableFindByNameUseCase(mockService) 118 | 119 | // Test data 120 | ctx := context.Background() 121 | name := "non-existent" 122 | 123 | // Execute use case 124 | result, err := useCase.Execute(ctx, name) 125 | 126 | // Assert results 127 | assert.Error(t, err) 128 | assert.Nil(t, result) 129 | assert.Contains(t, err.Error(), "failed to find example") 130 | mockService.AssertExpectations(t) 131 | } 132 | -------------------------------------------------------------------------------- /application/example/get.go: -------------------------------------------------------------------------------- 1 | package example 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "go-hexagonal/application/core" 8 | "go-hexagonal/domain/repo" 9 | "go-hexagonal/domain/service" 10 | "go-hexagonal/util/log" 11 | ) 12 | 13 | // GetUseCase handles the get example use case 14 | type GetUseCase struct { 15 | *core.UseCaseHandler 16 | exampleService service.IExampleService 17 | } 18 | 19 | // NewGetUseCase creates a new GetUseCase instance 20 | func NewGetUseCase( 21 | exampleService service.IExampleService, 22 | txFactory repo.TransactionFactory, 23 | ) *GetUseCase { 24 | return &GetUseCase{ 25 | UseCaseHandler: core.NewUseCaseHandler(txFactory), 26 | exampleService: exampleService, 27 | } 28 | } 29 | 30 | // Execute processes the get example request 31 | func (uc *GetUseCase) Execute(ctx context.Context, input any) (any, error) { 32 | // Convert and validate input 33 | getInput, ok := input.(*GetInput) 34 | if !ok { 35 | return nil, core.ValidationError("invalid input type", nil) 36 | } 37 | 38 | if err := getInput.Validate(); err != nil { 39 | return nil, err 40 | } 41 | 42 | // Retrieve example directly (no transaction needed) 43 | example, err := uc.exampleService.Get(ctx, getInput.ID) 44 | if err != nil { 45 | log.SugaredLogger.Errorf("Failed to get example: %v", err) 46 | return nil, fmt.Errorf("failed to get example: %w", err) 47 | } 48 | 49 | if example == nil { 50 | return nil, core.NotFoundError(fmt.Sprintf("example with ID %d not found", getInput.ID)) 51 | } 52 | 53 | // Create output DTO 54 | return NewExampleOutput(example), nil 55 | } 56 | -------------------------------------------------------------------------------- /application/example/get_test.go: -------------------------------------------------------------------------------- 1 | package example 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "testing" 7 | "time" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/mock" 11 | 12 | "go-hexagonal/api/dto" 13 | "go-hexagonal/domain/model" 14 | "go-hexagonal/domain/repo" 15 | ) 16 | 17 | // MockExampleService is defined in create_test.go 18 | 19 | // TestablGetUseCase 为测试目的修改GetUseCase 20 | type TestablGetUseCase struct { 21 | GetUseCase 22 | txProvider func(ctx context.Context) (repo.Transaction, error) 23 | } 24 | 25 | func NewTestablGetUseCase(svc *MockExampleService) *TestablGetUseCase { 26 | return &TestablGetUseCase{ 27 | GetUseCase: GetUseCase{ 28 | exampleService: svc, 29 | }, 30 | txProvider: CreateTestTransaction, 31 | } 32 | } 33 | 34 | // Execute 重写Execute方法以替换事务处理逻辑 35 | func (uc *TestablGetUseCase) Execute(ctx context.Context, id int) (*dto.GetExampleResponse, error) { 36 | // 使用测试事务 37 | tx, err := uc.txProvider(ctx) 38 | if err != nil { 39 | return nil, fmt.Errorf("创建事务失败: %w", err) 40 | } 41 | defer tx.Rollback() 42 | 43 | // 调用领域服务 44 | example, err := uc.exampleService.Get(ctx, id) 45 | if err != nil { 46 | return nil, fmt.Errorf("获取样例失败: %w", err) 47 | } 48 | 49 | // 提交事务 50 | if err = tx.Commit(); err != nil { 51 | return nil, fmt.Errorf("提交事务失败: %w", err) 52 | } 53 | 54 | // 将领域模型转换为DTO 55 | result := &dto.GetExampleResponse{ 56 | Id: example.Id, 57 | Name: example.Name, 58 | Alias: example.Alias, 59 | CreatedAt: example.CreatedAt, 60 | UpdatedAt: example.UpdatedAt, 61 | } 62 | 63 | return result, nil 64 | } 65 | 66 | // TestGetUseCase_Success 测试通过ID获取样例的成功情况 67 | func TestGetUseCase_Success(t *testing.T) { 68 | // 创建mock服务 69 | mockService := new(MockExampleService) 70 | 71 | // 测试数据 72 | exampleId := 1 73 | 74 | now := time.Now() 75 | expectedExample := &model.Example{ 76 | Id: exampleId, 77 | Name: "Test Example", 78 | Alias: "test", 79 | CreatedAt: now, 80 | UpdatedAt: now, 81 | } 82 | 83 | // 设置mock行为 84 | mockService.On("Get", mock.Anything, exampleId).Return(expectedExample, nil) 85 | 86 | // 使用可测试版本创建用例 87 | useCase := NewTestablGetUseCase(mockService) 88 | 89 | // 执行用例 90 | ctx := context.Background() 91 | result, err := useCase.Execute(ctx, exampleId) 92 | 93 | // 断言结果 94 | assert.NoError(t, err) 95 | assert.NotNil(t, result) 96 | assert.Equal(t, expectedExample.Id, result.Id) 97 | assert.Equal(t, expectedExample.Name, result.Name) 98 | assert.Equal(t, expectedExample.Alias, result.Alias) 99 | assert.Equal(t, expectedExample.CreatedAt, result.CreatedAt) 100 | assert.Equal(t, expectedExample.UpdatedAt, result.UpdatedAt) 101 | 102 | mockService.AssertExpectations(t) 103 | } 104 | 105 | // TestGetUseCase_Error 测试通过ID获取样例时出错的情况 106 | func TestGetUseCase_Error(t *testing.T) { 107 | // 创建mock服务 108 | mockService := new(MockExampleService) 109 | 110 | // 测试数据 111 | exampleId := 999 // 不存在的ID 112 | 113 | // 设置mock行为 - 模拟错误 114 | expectedError := assert.AnError 115 | mockService.On("Get", mock.Anything, exampleId).Return(nil, expectedError) 116 | 117 | // 使用可测试版本创建用例 118 | useCase := NewTestablGetUseCase(mockService) 119 | 120 | // 执行用例 121 | ctx := context.Background() 122 | result, err := useCase.Execute(ctx, exampleId) 123 | 124 | // 断言结果 125 | assert.Error(t, err) 126 | assert.Nil(t, result) 127 | assert.Contains(t, err.Error(), "获取样例失败") 128 | 129 | mockService.AssertExpectations(t) 130 | } 131 | -------------------------------------------------------------------------------- /application/example/main_test.go: -------------------------------------------------------------------------------- 1 | package example 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "go.uber.org/zap" 8 | 9 | "go-hexagonal/util/log" 10 | ) 11 | 12 | func TestMain(m *testing.M) { 13 | // 初始化日志配置 14 | initTestLogger() 15 | 16 | // 运行测试 17 | exitCode := m.Run() 18 | 19 | // 退出 20 | os.Exit(exitCode) 21 | } 22 | 23 | // initTestLogger 初始化测试环境的日志配置 24 | func initTestLogger() { 25 | // 使用最简单的控制台日志配置 26 | logger, _ := zap.NewDevelopment() 27 | zap.ReplaceGlobals(logger) 28 | 29 | // 初始化全局日志变量 30 | log.Logger = logger 31 | log.SugaredLogger = logger.Sugar() 32 | } 33 | -------------------------------------------------------------------------------- /application/example/mocks/service.go: -------------------------------------------------------------------------------- 1 | // Package mocks contains mock implementations for testing 2 | package mocks 3 | 4 | import ( 5 | "context" 6 | 7 | "github.com/stretchr/testify/mock" 8 | 9 | "go-hexagonal/domain/model" 10 | ) 11 | 12 | // ExampleService is a mock implementation of service.ExampleService 13 | type ExampleService struct { 14 | mock.Mock 15 | } 16 | 17 | // Create mocks the Create method 18 | func (m *ExampleService) Create(ctx context.Context, example *model.Example) (*model.Example, error) { 19 | args := m.Called(ctx, example) 20 | if args.Get(0) == nil { 21 | return nil, args.Error(1) 22 | } 23 | return args.Get(0).(*model.Example), args.Error(1) 24 | } 25 | 26 | // Delete mocks the Delete method 27 | func (m *ExampleService) Delete(ctx context.Context, id int) error { 28 | args := m.Called(ctx, id) 29 | return args.Error(0) 30 | } 31 | 32 | // Update mocks the Update method 33 | func (m *ExampleService) Update(ctx context.Context, example *model.Example) error { 34 | args := m.Called(ctx, example) 35 | return args.Error(0) 36 | } 37 | 38 | // Get mocks the Get method 39 | func (m *ExampleService) Get(ctx context.Context, id int) (*model.Example, error) { 40 | args := m.Called(ctx, id) 41 | if args.Get(0) == nil { 42 | return nil, args.Error(1) 43 | } 44 | return args.Get(0).(*model.Example), args.Error(1) 45 | } 46 | 47 | // FindByName mocks the FindByName method 48 | func (m *ExampleService) FindByName(ctx context.Context, name string) (*model.Example, error) { 49 | args := m.Called(ctx, name) 50 | if args.Get(0) == nil { 51 | return nil, args.Error(1) 52 | } 53 | return args.Get(0).(*model.Example), args.Error(1) 54 | } 55 | -------------------------------------------------------------------------------- /application/example/test_utils.go: -------------------------------------------------------------------------------- 1 | package example 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "github.com/stretchr/testify/mock" 8 | 9 | "go-hexagonal/domain/repo" 10 | ) 11 | 12 | // TestTransaction 测试用事务实现 13 | type TestTransaction struct { 14 | mock.Mock 15 | } 16 | 17 | // Begin 开始事务 18 | func (tx *TestTransaction) Begin() error { 19 | args := tx.Called() 20 | return args.Error(0) 21 | } 22 | 23 | // Commit 提交事务 24 | func (tx *TestTransaction) Commit() error { 25 | args := tx.Called() 26 | return args.Error(0) 27 | } 28 | 29 | // Rollback 回滚事务 30 | func (tx *TestTransaction) Rollback() error { 31 | args := tx.Called() 32 | return args.Error(0) 33 | } 34 | 35 | // Conn 获取底层连接 36 | func (tx *TestTransaction) Conn(ctx context.Context) any { 37 | args := tx.Called(ctx) 38 | return args.Get(0) 39 | } 40 | 41 | // Context 获取事务上下文 42 | func (tx *TestTransaction) Context() context.Context { 43 | args := tx.Called() 44 | return args.Get(0).(context.Context) 45 | } 46 | 47 | // WithContext 设置事务上下文 48 | func (tx *TestTransaction) WithContext(ctx context.Context) repo.Transaction { 49 | args := tx.Called(ctx) 50 | return args.Get(0).(repo.Transaction) 51 | } 52 | 53 | // StoreType 获取存储类型 54 | func (tx *TestTransaction) StoreType() repo.StoreType { 55 | args := tx.Called() 56 | return args.Get(0).(repo.StoreType) 57 | } 58 | 59 | // Options 获取事务选项 60 | func (tx *TestTransaction) Options() *repo.TransactionOptions { 61 | args := tx.Called() 62 | return args.Get(0).(*repo.TransactionOptions) 63 | } 64 | 65 | // CreateTestTransaction 创建测试用事务 66 | func CreateTestTransaction(ctx context.Context) (repo.Transaction, error) { 67 | tx := new(TestTransaction) 68 | tx.On("Begin").Return(nil) 69 | tx.On("Commit").Return(nil) 70 | tx.On("Rollback").Return(nil) 71 | tx.On("Context").Return(ctx) 72 | tx.On("StoreType").Return(repo.MySQLStore) 73 | tx.On("Options").Return(repo.DefaultTransactionOptions()) 74 | tx.On("WithContext", mock.Anything).Return(tx) 75 | return tx, nil 76 | } 77 | 78 | // ErrorTestTransaction 创建一个会失败的事务 79 | func ErrorTestTransaction(ctx context.Context) (repo.Transaction, error) { 80 | return nil, errors.New("创建事务失败") 81 | } 82 | 83 | // CommitErrorTestTransaction 创建一个提交会失败的事务 84 | func CommitErrorTestTransaction(ctx context.Context) (repo.Transaction, error) { 85 | tx := new(TestTransaction) 86 | tx.On("Begin").Return(nil) 87 | tx.On("Commit").Return(errors.New("提交事务失败")) 88 | tx.On("Rollback").Return(nil) 89 | tx.On("Context").Return(ctx) 90 | tx.On("StoreType").Return(repo.MySQLStore) 91 | tx.On("Options").Return(repo.DefaultTransactionOptions()) 92 | tx.On("WithContext", mock.Anything).Return(tx) 93 | return tx, nil 94 | } 95 | -------------------------------------------------------------------------------- /application/example/update.go: -------------------------------------------------------------------------------- 1 | package example 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "go-hexagonal/application/core" 8 | "go-hexagonal/domain/repo" 9 | "go-hexagonal/domain/service" 10 | "go-hexagonal/util/log" 11 | ) 12 | 13 | // UpdateUseCase handles the update example use case 14 | type UpdateUseCase struct { 15 | *core.UseCaseHandler 16 | exampleService service.IExampleService 17 | } 18 | 19 | // NewUpdateUseCase creates a new UpdateUseCase instance 20 | func NewUpdateUseCase( 21 | exampleService service.IExampleService, 22 | txFactory repo.TransactionFactory, 23 | ) *UpdateUseCase { 24 | return &UpdateUseCase{ 25 | UseCaseHandler: core.NewUseCaseHandler(txFactory), 26 | exampleService: exampleService, 27 | } 28 | } 29 | 30 | // Execute processes the update example request 31 | func (uc *UpdateUseCase) Execute(ctx context.Context, input any) (any, error) { 32 | // Convert and validate input 33 | updateInput, ok := input.(*UpdateInput) 34 | if !ok { 35 | return nil, core.ValidationError("invalid input type", nil) 36 | } 37 | 38 | if err := updateInput.Validate(); err != nil { 39 | return nil, err 40 | } 41 | 42 | // Execute in transaction 43 | result, err := uc.ExecuteInTransaction(ctx, repo.MySQLStore, func(ctx context.Context, tx repo.Transaction) (any, error) { 44 | // Call domain service to update the example 45 | err := uc.exampleService.Update(ctx, updateInput.ID, updateInput.Name, updateInput.Alias) 46 | if err != nil { 47 | log.SugaredLogger.Errorf("Failed to update example: %v", err) 48 | return nil, fmt.Errorf("failed to update example: %w", err) 49 | } 50 | 51 | // Get the updated example 52 | updatedExample, err := uc.exampleService.Get(ctx, updateInput.ID) 53 | if err != nil { 54 | log.SugaredLogger.Errorf("Failed to get updated example: %v", err) 55 | return nil, fmt.Errorf("failed to get updated example: %w", err) 56 | } 57 | 58 | // Create output DTO 59 | return NewExampleOutput(updatedExample), nil 60 | }) 61 | 62 | if err != nil { 63 | return nil, err 64 | } 65 | 66 | return result, nil 67 | } 68 | -------------------------------------------------------------------------------- /application/example/update_test.go: -------------------------------------------------------------------------------- 1 | package example 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/mock" 10 | 11 | "go-hexagonal/api/dto" 12 | "go-hexagonal/domain/model" 13 | "go-hexagonal/domain/repo" 14 | ) 15 | 16 | // MockExampleService is defined in create_test.go 17 | 18 | // testableUpdateUseCase modifies UpdateUseCase for testing purposes 19 | type testableUpdateUseCase struct { 20 | UpdateUseCase 21 | txProvider func(ctx context.Context) (repo.Transaction, error) 22 | } 23 | 24 | // newTestableUpdateUseCase creates a testable update use case 25 | func newTestableUpdateUseCase(svc *MockExampleService) *testableUpdateUseCase { 26 | return &testableUpdateUseCase{ 27 | UpdateUseCase: UpdateUseCase{ 28 | exampleService: svc, 29 | }, 30 | txProvider: CreateTestTransaction, 31 | } 32 | } 33 | 34 | // Execute overrides the Execute method to replace transaction handling logic 35 | func (uc *testableUpdateUseCase) Execute(ctx context.Context, input dto.UpdateExampleReq) error { 36 | // Use mock transaction 37 | tx, err := uc.txProvider(ctx) 38 | if err != nil { 39 | return fmt.Errorf("failed to create transaction: %w", err) 40 | } 41 | defer tx.Rollback() 42 | 43 | // Convert DTO to domain model 44 | example := &model.Example{ 45 | Id: int(input.Id), 46 | Name: input.Name, 47 | Alias: input.Alias, 48 | } 49 | 50 | // Call domain service 51 | if err := uc.exampleService.Update(ctx, example.Id, example.Name, example.Alias); err != nil { 52 | return fmt.Errorf("failed to update example: %w", err) 53 | } 54 | 55 | // Commit transaction 56 | if err = tx.Commit(); err != nil { 57 | return fmt.Errorf("failed to commit transaction: %w", err) 58 | } 59 | 60 | return nil 61 | } 62 | 63 | // TestUpdateUseCase_Execute_Success tests the successful case of updating an example 64 | func TestUpdateUseCase_Execute_Success(t *testing.T) { 65 | // Create mock service 66 | mockService := new(MockExampleService) 67 | 68 | // Test data 69 | updateReq := dto.UpdateExampleReq{ 70 | Id: 1, 71 | Name: "Updated Example", 72 | Alias: "updated", 73 | } 74 | 75 | // Setup mock behavior 76 | mockService.On("Update", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil) 77 | 78 | // Create testable use case 79 | useCase := newTestableUpdateUseCase(mockService) 80 | 81 | // Execute use case 82 | ctx := context.Background() 83 | err := useCase.Execute(ctx, updateReq) 84 | 85 | // Verify results 86 | assert.NoError(t, err) 87 | mockService.AssertExpectations(t) 88 | } 89 | 90 | // TestUpdateUseCase_Execute_Error tests the error case when updating an example 91 | func TestUpdateUseCase_Execute_Error(t *testing.T) { 92 | // Create mock service 93 | mockService := new(MockExampleService) 94 | 95 | // Test data 96 | updateReq := dto.UpdateExampleReq{ 97 | Id: 999, // Non-existent ID 98 | Name: "Updated Example", 99 | Alias: "updated", 100 | } 101 | 102 | // Setup mock behavior - simulate error 103 | expectedError := assert.AnError 104 | mockService.On("Update", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(expectedError) 105 | 106 | // Create testable use case 107 | useCase := newTestableUpdateUseCase(mockService) 108 | 109 | // Execute use case 110 | ctx := context.Background() 111 | err := useCase.Execute(ctx, updateReq) 112 | 113 | // Verify results 114 | assert.Error(t, err) 115 | assert.Contains(t, err.Error(), "failed to update example") 116 | mockService.AssertExpectations(t) 117 | } 118 | -------------------------------------------------------------------------------- /application/factory.go: -------------------------------------------------------------------------------- 1 | package application 2 | 3 | import ( 4 | "go-hexagonal/application/example" 5 | "go-hexagonal/domain/repo" 6 | "go-hexagonal/domain/service" 7 | ) 8 | 9 | // Factory provides methods to create application use cases 10 | type Factory struct { 11 | exampleService service.IExampleService 12 | txFactory repo.TransactionFactory 13 | } 14 | 15 | // NewFactory creates a new application factory 16 | func NewFactory( 17 | exampleService service.IExampleService, 18 | txFactory repo.TransactionFactory, 19 | ) *Factory { 20 | return &Factory{ 21 | exampleService: exampleService, 22 | txFactory: txFactory, 23 | } 24 | } 25 | 26 | // CreateExampleUseCase returns a new create example use case 27 | func (f *Factory) CreateExampleUseCase() *example.CreateUseCase { 28 | return example.NewCreateUseCase(f.exampleService, f.txFactory) 29 | } 30 | 31 | // DeleteExampleUseCase returns a new delete example use case 32 | func (f *Factory) DeleteExampleUseCase() *example.DeleteUseCase { 33 | return example.NewDeleteUseCase(f.exampleService, f.txFactory) 34 | } 35 | 36 | // UpdateExampleUseCase returns a new update example use case 37 | func (f *Factory) UpdateExampleUseCase() *example.UpdateUseCase { 38 | return example.NewUpdateUseCase(f.exampleService, f.txFactory) 39 | } 40 | 41 | // GetExampleUseCase returns a new get example use case 42 | func (f *Factory) GetExampleUseCase() *example.GetUseCase { 43 | return example.NewGetUseCase(f.exampleService, f.txFactory) 44 | } 45 | 46 | // FindExampleByNameUseCase returns a new find example by name use case 47 | func (f *Factory) FindExampleByNameUseCase() *example.FindByNameUseCase { 48 | return example.NewFindByNameUseCase(f.exampleService, f.txFactory) 49 | } 50 | 51 | // CreateExampleInput creates a new create example input 52 | func (f *Factory) CreateExampleInput(name, alias string) *example.CreateInput { 53 | return &example.CreateInput{ 54 | Name: name, 55 | Alias: alias, 56 | } 57 | } 58 | 59 | // UpdateExampleInput creates a new update example input 60 | func (f *Factory) UpdateExampleInput(id int, name, alias string) *example.UpdateInput { 61 | return &example.UpdateInput{ 62 | ID: id, 63 | Name: name, 64 | Alias: alias, 65 | } 66 | } 67 | 68 | // GetExampleInput creates a new get example input 69 | func (f *Factory) GetExampleInput(id int) *example.GetInput { 70 | return &example.GetInput{ 71 | ID: id, 72 | } 73 | } 74 | 75 | // DeleteExampleInput creates a new delete example input 76 | func (f *Factory) DeleteExampleInput(id int) *example.DeleteInput { 77 | return &example.DeleteInput{ 78 | ID: id, 79 | } 80 | } 81 | 82 | // FindExampleByNameInput creates a new find example by name input 83 | func (f *Factory) FindExampleByNameInput(name string) *example.FindByNameInput { 84 | return &example.FindByNameInput{ 85 | Name: name, 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /cmd/cmd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RanchoCooper/go-hexagonal/edf492f8da88a423ed49882d8f568241c8b01a50/cmd/cmd -------------------------------------------------------------------------------- /cmd/http_server/http_server.go: -------------------------------------------------------------------------------- 1 | package http_server 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | 7 | "github.com/spf13/cast" 8 | 9 | http2 "go-hexagonal/api/http" 10 | "go-hexagonal/config" 11 | "go-hexagonal/domain/service" 12 | "go-hexagonal/util/log" 13 | ) 14 | 15 | // Start initializes and starts the HTTP server 16 | func Start(ctx context.Context, errChan chan error, httpCloseCh chan struct{}, services *service.Services) { 17 | // Register services for API handlers to use 18 | http2.RegisterServices(services) 19 | 20 | // Initialize application factory 21 | http2.InitAppFactory(services) 22 | 23 | // Initialize server 24 | srv := &http.Server{ 25 | Addr: config.GlobalConfig.HTTPServer.Addr, 26 | Handler: http2.NewServerRoute(), 27 | ReadTimeout: cast.ToDuration(config.GlobalConfig.HTTPServer.ReadTimeout), 28 | WriteTimeout: cast.ToDuration(config.GlobalConfig.HTTPServer.WriteTimeout), 29 | } 30 | 31 | // Run server 32 | go func() { 33 | log.SugaredLogger.Infof("%s HTTP server is starting on %s", config.GlobalConfig.App.Name, config.GlobalConfig.HTTPServer.Addr) 34 | errChan <- srv.ListenAndServe() 35 | }() 36 | 37 | // Watch for context cancellation 38 | go func() { 39 | <-ctx.Done() 40 | if err := srv.Shutdown(ctx); err != nil { 41 | log.SugaredLogger.Infof("httpServer shutdown:%v", err) 42 | } 43 | httpCloseCh <- struct{}{} 44 | }() 45 | } 46 | -------------------------------------------------------------------------------- /cmd/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "os/signal" 8 | "syscall" 9 | "time" 10 | 11 | "go-hexagonal/adapter/dependency" 12 | "go-hexagonal/adapter/repository" 13 | "go-hexagonal/api/middleware" 14 | "go-hexagonal/cmd/http_server" 15 | "go-hexagonal/config" 16 | "go-hexagonal/util/log" 17 | 18 | "go.uber.org/zap" 19 | ) 20 | 21 | const ServiceName = "go-hexagonal" 22 | 23 | // Constants for application settings 24 | const ( 25 | // DefaultShutdownTimeout is the default timeout for graceful shutdown 26 | DefaultShutdownTimeout = 5 * time.Second 27 | // DefaultMetricsAddr is the default address for the metrics server 28 | DefaultMetricsAddr = ":9090" 29 | ) 30 | 31 | func main() { 32 | fmt.Println("Starting " + ServiceName) 33 | 34 | // Initialize configuration 35 | config.Init("./config", "config") 36 | fmt.Println("Configuration initialized") 37 | 38 | // Initialize logging 39 | log.Init() 40 | log.Logger.Info("Application starting", 41 | zap.String("service", ServiceName), 42 | zap.String("env", string(config.GlobalConfig.Env))) 43 | 44 | // Initialize metrics collection system 45 | middleware.InitializeMetrics() 46 | log.Logger.Info("Metrics collection system initialized") 47 | 48 | // Start metrics server in a separate goroutine if enabled 49 | if config.GlobalConfig.MetricsServer != nil && config.GlobalConfig.MetricsServer.Enabled { 50 | metricsAddr := config.GlobalConfig.MetricsServer.Addr 51 | if metricsAddr == "" { 52 | metricsAddr = DefaultMetricsAddr 53 | } 54 | go func() { 55 | if err := middleware.StartMetricsServer(metricsAddr); err != nil { 56 | log.Logger.Error("Failed to start metrics server", zap.Error(err)) 57 | } 58 | }() 59 | log.Logger.Info("Metrics server started", zap.String("address", metricsAddr)) 60 | } else { 61 | log.Logger.Info("Metrics server is disabled") 62 | } 63 | 64 | // Create context and cancel function 65 | ctx, cancel := context.WithCancel(context.Background()) 66 | defer cancel() 67 | 68 | // Initialize repositories using wire dependency injection with options 69 | log.Logger.Info("Initializing repositories") 70 | clients, err := dependency.InitializeRepositories( 71 | dependency.WithMySQL(), 72 | dependency.WithRedis(), 73 | ) 74 | if err != nil { 75 | log.Logger.Fatal("Failed to initialize repositories", 76 | zap.Error(err)) 77 | } 78 | repository.Clients = clients 79 | log.Logger.Info("Repositories initialized successfully") 80 | 81 | // Initialize services using dependency injection 82 | log.Logger.Info("Initializing services") 83 | services, err := dependency.InitializeServices(ctx, dependency.WithExampleService()) 84 | if err != nil { 85 | log.Logger.Fatal("Failed to initialize services", 86 | zap.Error(err)) 87 | } 88 | log.Logger.Info("Services initialized successfully") 89 | 90 | // Create error channel and HTTP close channel 91 | errChan := make(chan error, 1) 92 | httpCloseCh := make(chan struct{}, 1) 93 | 94 | // Start HTTP server 95 | log.Logger.Info("Starting HTTP server", 96 | zap.String("address", config.GlobalConfig.HTTPServer.Addr)) 97 | go http_server.Start(ctx, errChan, httpCloseCh, services) 98 | log.Logger.Info("HTTP server started") 99 | 100 | // Listen for signals 101 | sigChan := make(chan os.Signal, 1) 102 | signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) 103 | 104 | // Wait for signal or error 105 | select { 106 | case err := <-errChan: 107 | log.Logger.Error("Server error", zap.Error(err)) 108 | case sig := <-sigChan: 109 | log.Logger.Info("Received signal", zap.String("signal", sig.String())) 110 | } 111 | 112 | // Cancel context, trigger graceful shutdown 113 | log.Logger.Info("Shutting down server") 114 | cancel() 115 | 116 | // Set shutdown timeout 117 | shutdownTimeout := DefaultShutdownTimeout 118 | shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), shutdownTimeout) 119 | defer shutdownCancel() 120 | 121 | // Wait for HTTP server to close 122 | select { 123 | case <-httpCloseCh: 124 | log.Logger.Info("HTTP server shutdown completed") 125 | case <-shutdownCtx.Done(): 126 | log.Logger.Warn("HTTP server shutdown timed out", 127 | zap.Duration("timeout", DefaultShutdownTimeout)) 128 | } 129 | 130 | log.Logger.Info("Server gracefully stopped") 131 | } 132 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = {extends: ['@commitlint/config-conventional']} 2 | -------------------------------------------------------------------------------- /config/config.yaml: -------------------------------------------------------------------------------- 1 | env: dev 2 | app: 3 | name: go-hexagonal 4 | debug: true 5 | version: v1.0.0 6 | http_server: 7 | addr: :8080 8 | pprof: false 9 | default_page_size: 10 10 | max_page_size: 100 11 | read_timeout: 60s 12 | write_timeout: 60s 13 | metrics_server: 14 | addr: :9090 15 | enabled: true 16 | path: /metrics 17 | log: 18 | save_path: ./tmp 19 | file_name: app 20 | max_size: 100 21 | max_age: 30 22 | local_time: true 23 | compress: true 24 | level: debug 25 | enable_console: true 26 | enable_color: true 27 | enable_caller: true 28 | enable_stacktrace: false 29 | mysql: 30 | user: root 31 | password: root 32 | host: 127.0.0.1 33 | port: 3306 34 | database: go_hexagonal 35 | max_idle_conns: 10 36 | max_open_conns: 100 37 | max_life_time: 300s 38 | max_idle_time: 300s 39 | char_set: utf8mb4 40 | parse_time: true 41 | time_zone: Local 42 | redis: 43 | host: 127.0.0.1 44 | port: 6379 45 | password: "" 46 | db: 0 47 | poolSize: 10 48 | idleTimeout: 300 49 | minIdleConns: 5 50 | postgres: 51 | user: postgres 52 | password: postgres 53 | host: 127.0.0.1 54 | port: 5432 55 | database: go_hexagonal 56 | ssl_mode: disable 57 | options: "" 58 | max_connections: 100 59 | min_connections: 10 60 | max_conn_lifetime: 300 61 | idle_timeout: 300 62 | connect_timeout: 10 63 | time_zone: UTC 64 | mongodb: 65 | host: 127.0.0.1 66 | port: 27017 67 | database: go_hexagonal 68 | user: "" 69 | password: "" 70 | auth_source: admin 71 | options: "" 72 | min_pool_size: 5 73 | max_pool_size: 100 74 | idle_timeout: 300 75 | migration_dir: ./migrations 76 | -------------------------------------------------------------------------------- /domain/aggregate/aggregate.go: -------------------------------------------------------------------------------- 1 | package aggregate 2 | -------------------------------------------------------------------------------- /domain/event/event.go: -------------------------------------------------------------------------------- 1 | // Package event provides domain event functionality 2 | package event 3 | 4 | import ( 5 | "time" 6 | 7 | "github.com/google/uuid" 8 | ) 9 | 10 | // Event defines the domain event interface 11 | type Event interface { 12 | // EventName returns the event name 13 | EventName() string 14 | // AggregateID returns the aggregate ID 15 | AggregateID() string 16 | // OccurredAt returns when the event occurred 17 | OccurredAt() time.Time 18 | // EventID returns the event ID 19 | EventID() string 20 | } 21 | 22 | // BaseEvent provides a base implementation for events 23 | type BaseEvent struct { 24 | ID string `json:"id"` 25 | Name string `json:"name"` 26 | Aggregate string `json:"aggregate"` 27 | OccurredOn time.Time `json:"occurred_on"` 28 | Payload any `json:"payload"` 29 | } 30 | 31 | // NewBaseEvent creates a new base event 32 | func NewBaseEvent(name, aggregateID string, payload any) BaseEvent { 33 | return BaseEvent{ 34 | ID: uuid.New().String(), 35 | Name: name, 36 | Aggregate: aggregateID, 37 | OccurredOn: time.Now(), 38 | Payload: payload, 39 | } 40 | } 41 | 42 | // EventName returns the event name 43 | func (e BaseEvent) EventName() string { 44 | return e.Name 45 | } 46 | 47 | // AggregateID returns the aggregate ID 48 | func (e BaseEvent) AggregateID() string { 49 | return e.Aggregate 50 | } 51 | 52 | // OccurredAt returns when the event occurred 53 | func (e BaseEvent) OccurredAt() time.Time { 54 | return e.OccurredOn 55 | } 56 | 57 | // EventID returns the event ID 58 | func (e BaseEvent) EventID() string { 59 | return e.ID 60 | } 61 | -------------------------------------------------------------------------------- /domain/event/event_bus.go: -------------------------------------------------------------------------------- 1 | package event 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | ) 7 | 8 | // EventHandler defines the event handler interface 9 | type EventHandler interface { 10 | // HandleEvent processes an event 11 | HandleEvent(ctx context.Context, event Event) error 12 | // InterestedIn checks if the handler is interested in the event 13 | InterestedIn(eventName string) bool 14 | } 15 | 16 | // EventBus defines the event bus interface 17 | type EventBus interface { 18 | // Publish publishes an event 19 | Publish(ctx context.Context, event Event) error 20 | // Subscribe registers an event handler 21 | Subscribe(handler EventHandler) 22 | // Unsubscribe removes an event handler 23 | Unsubscribe(handler EventHandler) 24 | } 25 | 26 | // NoopEventBus implements a no-operation event bus 27 | type NoopEventBus struct{} 28 | 29 | // NewNoopEventBus creates a new no-operation event bus 30 | func NewNoopEventBus() *NoopEventBus { 31 | return &NoopEventBus{} 32 | } 33 | 34 | // Publish does nothing and returns nil 35 | func (b *NoopEventBus) Publish(ctx context.Context, event Event) error { 36 | return nil 37 | } 38 | 39 | // Subscribe does nothing 40 | func (b *NoopEventBus) Subscribe(handler EventHandler) {} 41 | 42 | // Unsubscribe does nothing 43 | func (b *NoopEventBus) Unsubscribe(handler EventHandler) {} 44 | 45 | // InMemoryEventBus implements an in-memory event bus 46 | type InMemoryEventBus struct { 47 | handlers []EventHandler 48 | mu sync.RWMutex 49 | } 50 | 51 | // NewInMemoryEventBus creates a new in-memory event bus 52 | func NewInMemoryEventBus() *InMemoryEventBus { 53 | return &InMemoryEventBus{ 54 | handlers: make([]EventHandler, 0), 55 | } 56 | } 57 | 58 | // Publish publishes an event to all interested handlers 59 | func (b *InMemoryEventBus) Publish(ctx context.Context, event Event) error { 60 | b.mu.RLock() 61 | defer b.mu.RUnlock() 62 | 63 | for _, handler := range b.handlers { 64 | if handler.InterestedIn(event.EventName()) { 65 | if err := handler.HandleEvent(ctx, event); err != nil { 66 | return err 67 | } 68 | } 69 | } 70 | return nil 71 | } 72 | 73 | // Subscribe registers an event handler 74 | func (b *InMemoryEventBus) Subscribe(handler EventHandler) { 75 | b.mu.Lock() 76 | defer b.mu.Unlock() 77 | 78 | b.handlers = append(b.handlers, handler) 79 | } 80 | 81 | // Unsubscribe removes an event handler 82 | func (b *InMemoryEventBus) Unsubscribe(handler EventHandler) { 83 | b.mu.Lock() 84 | defer b.mu.Unlock() 85 | 86 | for i, h := range b.handlers { 87 | if h == handler { 88 | b.handlers = append(b.handlers[:i], b.handlers[i+1:]...) 89 | break 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /domain/event/event_test.go: -------------------------------------------------------------------------------- 1 | package event 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestNewBaseEvent(t *testing.T) { 11 | // Test data 12 | name := "test.event" 13 | aggregateID := "123" 14 | payload := map[string]string{"key": "value"} 15 | 16 | // Create a new base event 17 | event := NewBaseEvent(name, aggregateID, payload) 18 | 19 | // Assertions 20 | assert.Equal(t, name, event.Name) 21 | assert.Equal(t, aggregateID, event.Aggregate) 22 | assert.Equal(t, payload, event.Payload) 23 | assert.NotEmpty(t, event.ID) 24 | assert.NotZero(t, event.OccurredOn) 25 | 26 | // Time should be close to now 27 | assert.WithinDuration(t, time.Now(), event.OccurredOn, 2*time.Second) 28 | } 29 | 30 | func TestBaseEvent_EventName(t *testing.T) { 31 | // Create a test event 32 | event := BaseEvent{ 33 | Name: "test.event", 34 | } 35 | 36 | // Test EventName method 37 | assert.Equal(t, "test.event", event.EventName()) 38 | } 39 | 40 | func TestBaseEvent_AggregateID(t *testing.T) { 41 | // Create a test event 42 | event := BaseEvent{ 43 | Aggregate: "123", 44 | } 45 | 46 | // Test AggregateID method 47 | assert.Equal(t, "123", event.AggregateID()) 48 | } 49 | 50 | func TestBaseEvent_OccurredAt(t *testing.T) { 51 | // Create a test event with specific time 52 | now := time.Now() 53 | event := BaseEvent{ 54 | OccurredOn: now, 55 | } 56 | 57 | // Test OccurredAt method 58 | assert.Equal(t, now, event.OccurredAt()) 59 | } 60 | 61 | func TestBaseEvent_EventID(t *testing.T) { 62 | // Create a test event 63 | event := BaseEvent{ 64 | ID: "event-123", 65 | } 66 | 67 | // Test EventID method 68 | assert.Equal(t, "event-123", event.EventID()) 69 | } 70 | -------------------------------------------------------------------------------- /domain/event/example_events.go: -------------------------------------------------------------------------------- 1 | package event 2 | 3 | import ( 4 | "strconv" 5 | ) 6 | 7 | const ( 8 | // ExampleCreatedEventName is the name for example creation events 9 | ExampleCreatedEventName = "example.created" 10 | // ExampleUpdatedEventName is the name for example update events 11 | ExampleUpdatedEventName = "example.updated" 12 | // ExampleDeletedEventName is the name for example deletion events 13 | ExampleDeletedEventName = "example.deleted" 14 | ) 15 | 16 | // ExampleCreatedPayload contains data for example creation events 17 | type ExampleCreatedPayload struct { 18 | ID int `json:"id"` 19 | Name string `json:"name"` 20 | Alias string `json:"alias"` 21 | } 22 | 23 | // ExampleCreatedEvent represents an example creation event 24 | type ExampleCreatedEvent struct { 25 | BaseEvent 26 | } 27 | 28 | // NewExampleCreatedEvent creates a new example creation event 29 | func NewExampleCreatedEvent(id int, name, alias string) ExampleCreatedEvent { 30 | payload := ExampleCreatedPayload{ 31 | ID: id, 32 | Name: name, 33 | Alias: alias, 34 | } 35 | return ExampleCreatedEvent{ 36 | BaseEvent: NewBaseEvent(ExampleCreatedEventName, strconv.Itoa(id), payload), 37 | } 38 | } 39 | 40 | // ExampleUpdatedPayload contains data for example update events 41 | type ExampleUpdatedPayload struct { 42 | ID int `json:"id"` 43 | Name string `json:"name"` 44 | Alias string `json:"alias"` 45 | } 46 | 47 | // ExampleUpdatedEvent represents an example update event 48 | type ExampleUpdatedEvent struct { 49 | BaseEvent 50 | } 51 | 52 | // NewExampleUpdatedEvent creates a new example update event 53 | func NewExampleUpdatedEvent(id int, name, alias string) ExampleUpdatedEvent { 54 | payload := ExampleUpdatedPayload{ 55 | ID: id, 56 | Name: name, 57 | Alias: alias, 58 | } 59 | return ExampleUpdatedEvent{ 60 | BaseEvent: NewBaseEvent(ExampleUpdatedEventName, strconv.Itoa(id), payload), 61 | } 62 | } 63 | 64 | // ExampleDeletedPayload contains data for example deletion events 65 | type ExampleDeletedPayload struct { 66 | ID int `json:"id"` 67 | } 68 | 69 | // ExampleDeletedEvent represents an example deletion event 70 | type ExampleDeletedEvent struct { 71 | BaseEvent 72 | } 73 | 74 | // NewExampleDeletedEvent creates a new example deletion event 75 | func NewExampleDeletedEvent(id int) ExampleDeletedEvent { 76 | payload := ExampleDeletedPayload{ 77 | ID: id, 78 | } 79 | return ExampleDeletedEvent{ 80 | BaseEvent: NewBaseEvent(ExampleDeletedEventName, strconv.Itoa(id), payload), 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /domain/event/example_handlers.go: -------------------------------------------------------------------------------- 1 | package event 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | 7 | "go-hexagonal/util/log" 8 | 9 | "go.uber.org/zap" 10 | ) 11 | 12 | // LoggingEventHandler logs all events it receives 13 | type LoggingEventHandler struct { 14 | interestedEvents []string 15 | } 16 | 17 | // NewLoggingEventHandler creates a new logging event handler 18 | func NewLoggingEventHandler(events ...string) *LoggingEventHandler { 19 | return &LoggingEventHandler{ 20 | interestedEvents: events, 21 | } 22 | } 23 | 24 | // HandleEvent logs the event details 25 | func (h *LoggingEventHandler) HandleEvent(ctx context.Context, event Event) error { 26 | eventData, _ := json.Marshal(event) 27 | log.Logger.Info("Event received", 28 | zap.String("event_name", event.EventName()), 29 | zap.String("event_data", string(eventData))) 30 | return nil 31 | } 32 | 33 | // InterestedIn checks if the handler is interested in the event 34 | func (h *LoggingEventHandler) InterestedIn(eventName string) bool { 35 | if len(h.interestedEvents) == 0 { 36 | return true 37 | } 38 | for _, name := range h.interestedEvents { 39 | if name == eventName { 40 | return true 41 | } 42 | } 43 | return false 44 | } 45 | 46 | // ExampleEventHandler handles example-related events 47 | type ExampleEventHandler struct { 48 | } 49 | 50 | // NewExampleEventHandler creates a new example event handler 51 | func NewExampleEventHandler() *ExampleEventHandler { 52 | return &ExampleEventHandler{} 53 | } 54 | 55 | // HandleEvent handles the example event based on its type 56 | func (h *ExampleEventHandler) HandleEvent(ctx context.Context, event Event) error { 57 | switch event.EventName() { 58 | case ExampleCreatedEventName: 59 | return h.handleExampleCreated(ctx, event) 60 | case ExampleUpdatedEventName: 61 | return h.handleExampleUpdated(ctx, event) 62 | case ExampleDeletedEventName: 63 | return h.handleExampleDeleted(ctx, event) 64 | default: 65 | return nil 66 | } 67 | } 68 | 69 | // InterestedIn checks if the handler is interested in the event 70 | func (h *ExampleEventHandler) InterestedIn(eventName string) bool { 71 | return eventName == ExampleCreatedEventName || 72 | eventName == ExampleUpdatedEventName || 73 | eventName == ExampleDeletedEventName 74 | } 75 | 76 | // handleExampleCreated handles example creation events 77 | func (h *ExampleEventHandler) handleExampleCreated(ctx context.Context, event Event) error { 78 | log.Logger.Info("Example created", 79 | zap.String("id", event.AggregateID()), 80 | zap.String("event_id", event.EventID())) 81 | return nil 82 | } 83 | 84 | // handleExampleUpdated handles example update events 85 | func (h *ExampleEventHandler) handleExampleUpdated(ctx context.Context, event Event) error { 86 | log.Logger.Info("Example updated", 87 | zap.String("id", event.AggregateID()), 88 | zap.String("event_id", event.EventID())) 89 | return nil 90 | } 91 | 92 | // handleExampleDeleted handles example deletion events 93 | func (h *ExampleEventHandler) handleExampleDeleted(ctx context.Context, event Event) error { 94 | log.Logger.Info("Example deleted", 95 | zap.String("id", event.AggregateID()), 96 | zap.String("event_id", event.EventID())) 97 | return nil 98 | } 99 | -------------------------------------------------------------------------------- /domain/model/errors.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | stderrors "errors" 5 | 6 | "go-hexagonal/util/errors" 7 | ) 8 | 9 | // Example related domain errors 10 | var ( 11 | // ErrExampleNotFound indicates the requested example was not found 12 | ErrExampleNotFound = errors.New(errors.ErrorTypeNotFound, "example not found") 13 | 14 | // ErrEmptyExampleName indicates an empty example name was provided 15 | ErrEmptyExampleName = errors.New(errors.ErrorTypeValidation, "example name cannot be empty") 16 | 17 | // ErrInvalidExampleID indicates an invalid example ID was provided 18 | ErrInvalidExampleID = errors.New(errors.ErrorTypeValidation, "invalid example ID") 19 | 20 | // ErrExampleNameTaken indicates an example with the given name already exists 21 | ErrExampleNameTaken = errors.New(errors.ErrorTypeConflict, "example name already taken") 22 | 23 | // ErrExampleInvalidUpdate indicates an attempt to update with invalid data 24 | ErrExampleInvalidUpdate = errors.New(errors.ErrorTypeValidation, "invalid example update data") 25 | 26 | // ErrExampleModified indicates the example was modified concurrently 27 | ErrExampleModified = errors.New(errors.ErrorTypeConflict, "example modified by another process") 28 | ) 29 | 30 | // NewExampleNotFoundWithID creates a not found error with the example ID 31 | func NewExampleNotFoundWithID(id int) *errors.AppError { 32 | return errors.Newf(errors.ErrorTypeNotFound, "example with ID %d not found", id) 33 | } 34 | 35 | // NewExampleNotFoundWithName creates a not found error with the example name 36 | func NewExampleNotFoundWithName(name string) *errors.AppError { 37 | return errors.Newf(errors.ErrorTypeNotFound, "example with name '%s' not found", name) 38 | } 39 | 40 | // NewExampleNameTakenError creates an error indicating the name is already taken 41 | func NewExampleNameTakenError(name string) *errors.AppError { 42 | return errors.Newf(errors.ErrorTypeConflict, "example with name '%s' already exists", name) 43 | } 44 | 45 | // IsExampleNotFoundError checks if the error indicates an example not found condition 46 | func IsExampleNotFoundError(err error) bool { 47 | return stderrors.Is(err, ErrExampleNotFound) || errors.IsNotFoundError(err) 48 | } 49 | 50 | // IsExampleValidationError checks if the error is related to example validation 51 | func IsExampleValidationError(err error) bool { 52 | return stderrors.Is(err, ErrEmptyExampleName) || 53 | stderrors.Is(err, ErrInvalidExampleID) || 54 | stderrors.Is(err, ErrExampleInvalidUpdate) || 55 | errors.IsValidationError(err) 56 | } 57 | 58 | // IsExampleNameTakenError checks if the error indicates a name conflict 59 | func IsExampleNameTakenError(err error) bool { 60 | return stderrors.Is(err, ErrExampleNameTaken) 61 | } 62 | 63 | // IsExampleModifiedError checks if the error indicates a concurrent modification 64 | func IsExampleModifiedError(err error) bool { 65 | return stderrors.Is(err, ErrExampleModified) 66 | } 67 | -------------------------------------------------------------------------------- /domain/model/example.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | // Example represents a basic example entity 8 | type Example struct { 9 | Id int `json:"id"` 10 | Name string `json:"name"` 11 | Alias string `json:"alias"` 12 | CreatedAt time.Time `json:"created_at"` 13 | UpdatedAt time.Time `json:"updated_at"` 14 | events []DomainEvent // Track domain events 15 | } 16 | 17 | // DomainEvent represents a domain event interface 18 | type DomainEvent interface { 19 | EventType() string 20 | } 21 | 22 | // NewExample creates a new Example entity with validation 23 | func NewExample(name, alias string) (*Example, error) { 24 | if name == "" { 25 | return nil, ErrEmptyExampleName 26 | } 27 | 28 | example := &Example{ 29 | Name: name, 30 | Alias: alias, 31 | CreatedAt: time.Now(), 32 | UpdatedAt: time.Now(), 33 | events: make([]DomainEvent, 0), 34 | } 35 | 36 | // Record creation event 37 | example.addEvent(NewExampleCreatedEvent(example)) 38 | 39 | return example, nil 40 | } 41 | 42 | // Validate ensures the Example entity meets domain rules 43 | func (e *Example) Validate() error { 44 | if e.Name == "" { 45 | return ErrEmptyExampleName 46 | } 47 | if e.Id < 0 { 48 | return ErrInvalidExampleID 49 | } 50 | return nil 51 | } 52 | 53 | // Update changes the Example entity with validation 54 | func (e *Example) Update(name, alias string) error { 55 | if name == "" { 56 | return ErrEmptyExampleName 57 | } 58 | 59 | e.Name = name 60 | e.Alias = alias 61 | e.UpdatedAt = time.Now() 62 | 63 | // Record update event 64 | e.addEvent(NewExampleUpdatedEvent(e)) 65 | 66 | return nil 67 | } 68 | 69 | // MarkDeleted marks the entity as deleted and records a deletion event 70 | func (e *Example) MarkDeleted() { 71 | e.addEvent(NewExampleDeletedEvent(e)) 72 | } 73 | 74 | // Events returns all accumulated domain events and clears the event list 75 | func (e *Example) Events() []DomainEvent { 76 | events := e.events 77 | e.events = make([]DomainEvent, 0) 78 | return events 79 | } 80 | 81 | // addEvent adds a domain event to the entity 82 | func (e *Example) addEvent(event DomainEvent) { 83 | e.events = append(e.events, event) 84 | } 85 | 86 | // TableName returns the table name for the Example model 87 | // This is kept for persistence adapters but is not part of domain logic 88 | func (e Example) TableName() string { 89 | return "example" 90 | } 91 | 92 | // Domain events for Example entity 93 | 94 | // ExampleCreatedEvent represents the creation of an example 95 | type ExampleCreatedEvent struct { 96 | ExampleID int 97 | Name string 98 | Alias string 99 | Timestamp time.Time 100 | } 101 | 102 | // EventType returns the event type 103 | func (e ExampleCreatedEvent) EventType() string { 104 | return "example.created" 105 | } 106 | 107 | // NewExampleCreatedEvent creates a new example created event 108 | func NewExampleCreatedEvent(example *Example) ExampleCreatedEvent { 109 | return ExampleCreatedEvent{ 110 | ExampleID: example.Id, 111 | Name: example.Name, 112 | Alias: example.Alias, 113 | Timestamp: time.Now(), 114 | } 115 | } 116 | 117 | // ExampleUpdatedEvent represents an update to an example 118 | type ExampleUpdatedEvent struct { 119 | ExampleID int 120 | Name string 121 | Alias string 122 | Timestamp time.Time 123 | } 124 | 125 | // EventType returns the event type 126 | func (e ExampleUpdatedEvent) EventType() string { 127 | return "example.updated" 128 | } 129 | 130 | // NewExampleUpdatedEvent creates a new example updated event 131 | func NewExampleUpdatedEvent(example *Example) ExampleUpdatedEvent { 132 | return ExampleUpdatedEvent{ 133 | ExampleID: example.Id, 134 | Name: example.Name, 135 | Alias: example.Alias, 136 | Timestamp: time.Now(), 137 | } 138 | } 139 | 140 | // ExampleDeletedEvent represents the deletion of an example 141 | type ExampleDeletedEvent struct { 142 | ExampleID int 143 | Timestamp time.Time 144 | } 145 | 146 | // EventType returns the event type 147 | func (e ExampleDeletedEvent) EventType() string { 148 | return "example.deleted" 149 | } 150 | 151 | // NewExampleDeletedEvent creates a new example deleted event 152 | func NewExampleDeletedEvent(example *Example) ExampleDeletedEvent { 153 | return ExampleDeletedEvent{ 154 | ExampleID: example.Id, 155 | Timestamp: time.Now(), 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /domain/repo/error.go: -------------------------------------------------------------------------------- 1 | package repo 2 | 3 | // Common errors for domain repositories 4 | type RepoError string 5 | 6 | func (e RepoError) Error() string { 7 | return string(e) 8 | } 9 | 10 | // Common repository errors 11 | var ( 12 | // ErrNotFound is returned when a requested entity is not found 13 | ErrNotFound = RepoError("entity not found") 14 | ) 15 | -------------------------------------------------------------------------------- /domain/repo/example.go: -------------------------------------------------------------------------------- 1 | package repo 2 | 3 | import ( 4 | "context" 5 | 6 | "go-hexagonal/domain/model" 7 | ) 8 | 9 | // IExampleRepo defines the interface for example repository 10 | type IExampleRepo interface { 11 | Create(ctx context.Context, tr Transaction, example *model.Example) (*model.Example, error) 12 | Delete(ctx context.Context, tr Transaction, id int) error 13 | Update(ctx context.Context, tr Transaction, entity *model.Example) error 14 | GetByID(ctx context.Context, tr Transaction, Id int) (*model.Example, error) 15 | FindByName(ctx context.Context, tr Transaction, name string) (*model.Example, error) 16 | } 17 | 18 | // IExampleCacheRepo defines the interface for example cache repository 19 | type IExampleCacheRepo interface { 20 | HealthCheck(ctx context.Context) error 21 | GetByID(ctx context.Context, id int) (*model.Example, error) 22 | GetByName(ctx context.Context, name string) (*model.Example, error) 23 | Set(ctx context.Context, example *model.Example) error 24 | Delete(ctx context.Context, id int) error 25 | Invalidate(ctx context.Context) error 26 | } 27 | -------------------------------------------------------------------------------- /domain/repo/transaction_factory.go: -------------------------------------------------------------------------------- 1 | package repo 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | // TransactionFactory defines an interface for creating transactions 8 | type TransactionFactory interface { 9 | // NewTransaction creates a new transaction with the specified store type and options 10 | NewTransaction(ctx context.Context, store StoreType, opts any) (Transaction, error) 11 | } 12 | 13 | // NoopTransactionFactory is a no-operation transaction factory implementation 14 | type NoopTransactionFactory struct{} 15 | 16 | // NewNoOpTransactionFactory creates a new NoopTransactionFactory 17 | func NewNoOpTransactionFactory() TransactionFactory { 18 | return &NoopTransactionFactory{} 19 | } 20 | 21 | // NewTransaction creates a new no-operation transaction 22 | func (f *NoopTransactionFactory) NewTransaction(ctx context.Context, store StoreType, opts any) (Transaction, error) { 23 | return NewNoopTransaction(nil), nil 24 | } 25 | -------------------------------------------------------------------------------- /domain/service/converter.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import "go-hexagonal/domain/model" 4 | 5 | // Converter defines an interface for converting between domain models and data transfer objects 6 | type Converter interface { 7 | // ToExampleResponse converts a domain model to a response object 8 | ToExampleResponse(example *model.Example) (any, error) 9 | 10 | // FromCreateRequest converts a request object to a domain model 11 | FromCreateRequest(req any) (*model.Example, error) 12 | 13 | // FromUpdateRequest converts an update request to a domain model 14 | FromUpdateRequest(req any) (*model.Example, error) 15 | } 16 | -------------------------------------------------------------------------------- /domain/service/iexample_service.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | 6 | "go-hexagonal/domain/model" 7 | ) 8 | 9 | // IExampleService defines the interface for example service 10 | // This allows the application layer to depend on interfaces rather than concrete implementations, 11 | // facilitating testing and adhering to the dependency inversion principle 12 | type IExampleService interface { 13 | // Create creates a new example with the given name and alias 14 | // Returns the created example or an error if validation or persistence fails 15 | Create(ctx context.Context, name string, alias string) (*model.Example, error) 16 | 17 | // Delete deletes an example by ID 18 | // Returns an error if the example doesn't exist or deletion fails 19 | Delete(ctx context.Context, id int) error 20 | 21 | // Update updates an example with the given ID, name and alias 22 | // Returns an error if the example doesn't exist, validation fails, or update fails 23 | Update(ctx context.Context, id int, name string, alias string) error 24 | 25 | // Get retrieves an example by ID 26 | // Returns the example or an error if not found 27 | Get(ctx context.Context, id int) (*model.Example, error) 28 | 29 | // FindByName finds examples by name 30 | // Returns the example or an error if not found 31 | FindByName(ctx context.Context, name string) (*model.Example, error) 32 | } 33 | -------------------------------------------------------------------------------- /domain/service/main_test.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "testing" 5 | 6 | "go-hexagonal/adapter/repository" 7 | "go-hexagonal/config" 8 | "go-hexagonal/util/log" 9 | 10 | "gorm.io/gorm" 11 | ) 12 | 13 | func TestMain(m *testing.M) { 14 | // Initialize configuration and logging 15 | config.Init("../../config", "config") 16 | log.Init() 17 | 18 | repository.Clients = &repository.ClientContainer{ 19 | MySQL: repository.NewMySQLClient(&gorm.DB{}), 20 | Redis: repository.NewRedisClient(), 21 | } 22 | 23 | m.Run() 24 | } 25 | -------------------------------------------------------------------------------- /domain/service/service.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "go-hexagonal/domain/event" 5 | "go-hexagonal/domain/repo" 6 | ) 7 | 8 | // ExampleRepoFactory defines the interface for example repository factory 9 | type ExampleRepoFactory interface { 10 | CreateExampleRepo() repo.IExampleRepo 11 | } 12 | 13 | // Services contains all service instances 14 | type Services struct { 15 | ExampleService *ExampleService 16 | EventBus event.EventBus 17 | Converter Converter 18 | } 19 | 20 | // NewServices creates a services collection 21 | func NewServices(exampleService *ExampleService, eventBus event.EventBus) *Services { 22 | return &Services{ 23 | ExampleService: exampleService, 24 | EventBus: eventBus, 25 | } 26 | } 27 | 28 | // WithConverter adds a converter to the services 29 | func (s *Services) WithConverter(converter Converter) *Services { 30 | s.Converter = converter 31 | return s 32 | } 33 | -------------------------------------------------------------------------------- /domain/vo/vo.go: -------------------------------------------------------------------------------- 1 | package vo 2 | -------------------------------------------------------------------------------- /tests/main_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "testing" 5 | 6 | "go-hexagonal/config" 7 | ) 8 | 9 | func TestMain(m *testing.M) { 10 | config.Init("../config", "config") 11 | 12 | m.Run() 13 | } 14 | -------------------------------------------------------------------------------- /tests/migrations/000001_create_user_table.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS users; 2 | 3 | DROP INDEX IF EXISTS users_uid_idx; 4 | -------------------------------------------------------------------------------- /tests/migrations/000001_create_user_table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE users 2 | ( 3 | id SERIAL PRIMARY KEY, 4 | name VARCHAR(255) NOT NULL, 5 | email VARCHAR(255) UNIQUE NOT NULL, 6 | uid VARCHAR(255) UNIQUE NOT NULL, 7 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 8 | updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 9 | deleted_at TIMESTAMP DEFAULT NULL 10 | ); 11 | 12 | CREATE INDEX users_uid_idx ON users (uid); 13 | -------------------------------------------------------------------------------- /tests/migrations/migrate/migrate.go: -------------------------------------------------------------------------------- 1 | package migrate 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "github.com/golang-migrate/migrate/v4" 8 | _ "github.com/golang-migrate/migrate/v4/database/mysql" 9 | _ "github.com/golang-migrate/migrate/v4/database/postgres" 10 | _ "github.com/golang-migrate/migrate/v4/source/file" 11 | _ "github.com/lib/pq" 12 | 13 | "go-hexagonal/config" 14 | ) 15 | 16 | func PostgreMigrateUp(conf *config.Config) error { 17 | if conf.MigrationDir == "" { 18 | return nil 19 | } 20 | 21 | m, err := migrate.New( 22 | "file://"+conf.MigrationDir, 23 | fmt.Sprintf( 24 | "postgres://%s:%s@%s:%d/%s?sslmode=%s", 25 | conf.Postgre.User, 26 | conf.Postgre.Password, 27 | conf.Postgre.Host, 28 | conf.Postgre.Port, 29 | conf.Postgre.Database, 30 | conf.Postgre.SSLMode, 31 | ), 32 | ) 33 | if err != nil { 34 | return err 35 | } 36 | defer m.Close() 37 | 38 | if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) { 39 | return err 40 | } 41 | 42 | return nil 43 | } 44 | 45 | func PostgreMigrateDrop(conf *config.Config) error { 46 | m, err := migrate.New( 47 | "file://"+conf.MigrationDir, 48 | fmt.Sprintf( 49 | "postgresql://%s:%s@%s:%d/%s?sslmode=%s", 50 | conf.Postgre.User, 51 | conf.Postgre.Password, 52 | conf.Postgre.Host, 53 | conf.Postgre.Port, 54 | conf.Postgre.Database, 55 | conf.Postgre.SSLMode, 56 | ), 57 | ) 58 | if err != nil { 59 | return err 60 | } 61 | defer m.Close() 62 | 63 | if err := m.Drop(); err != nil && !errors.Is(err, migrate.ErrNoChange) { 64 | return err 65 | } 66 | 67 | return nil 68 | } 69 | 70 | func MySQLMigrateUp(conf *config.Config) error { 71 | if conf.MigrationDir == "" { 72 | return nil 73 | } 74 | 75 | m, err := migrate.New( 76 | "file://"+conf.MigrationDir, 77 | fmt.Sprintf( 78 | "mysql://%s:%s@tcp(%s:%d)/%s", 79 | conf.MySQL.User, 80 | conf.MySQL.Password, 81 | conf.MySQL.Host, 82 | conf.MySQL.Port, 83 | conf.MySQL.Database, 84 | ), 85 | ) 86 | if err != nil { 87 | return err 88 | } 89 | defer m.Close() 90 | 91 | if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) { 92 | return err 93 | } 94 | 95 | return nil 96 | } 97 | 98 | func MySQLMigrateDrop(conf *config.Config) error { 99 | m, err := migrate.New( 100 | "file://"+conf.MigrationDir, 101 | fmt.Sprintf( 102 | "mysql://%s:%s@tcp(%s:%d)/%s", 103 | conf.MySQL.User, 104 | conf.MySQL.Password, 105 | conf.MySQL.Host, 106 | conf.MySQL.Port, 107 | conf.MySQL.Database, 108 | ), 109 | ) 110 | if err != nil { 111 | return err 112 | } 113 | defer m.Close() 114 | 115 | if err := m.Drop(); err != nil && !errors.Is(err, migrate.ErrNoChange) { 116 | return err 117 | } 118 | 119 | return nil 120 | } 121 | -------------------------------------------------------------------------------- /tests/mysql.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "testing" 8 | "time" 9 | 10 | "github.com/testcontainers/testcontainers-go" 11 | "github.com/testcontainers/testcontainers-go/wait" 12 | driver "gorm.io/driver/mysql" 13 | "gorm.io/gorm" 14 | 15 | "go-hexagonal/config" 16 | "go-hexagonal/tests/migrations/migrate" 17 | ) 18 | 19 | const ( 20 | MysqlStartTimeout = 2 * time.Minute 21 | ) 22 | 23 | func SetupMySQL(t *testing.T) *config.MySQLConfig { 24 | t.Log("Setting up an instance of MySQL with testcontainers-go") 25 | ctx := context.Background() 26 | 27 | user, password, dbName := "user", "123456", "test" 28 | 29 | req := testcontainers.ContainerRequest{ 30 | Image: "mysql:8.0", 31 | ExposedPorts: []string{"3306/tcp"}, 32 | Env: map[string]string{ 33 | "MYSQL_USER": user, 34 | "MYSQL_ROOT_PASSWORD": password, 35 | "MYSQL_PASSWORD": password, 36 | "MYSQL_DATABASE": dbName, 37 | }, 38 | WaitingFor: wait.ForAll( 39 | wait.ForListeningPort("3306/tcp").WithStartupTimeout(MysqlStartTimeout), 40 | wait.ForLog("ready for connections"), 41 | ), 42 | } 43 | 44 | db, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ 45 | ContainerRequest: req, 46 | Started: true, 47 | }) 48 | if err != nil { 49 | t.Fatalf("could not start Docker container, err: %s", err) 50 | } 51 | 52 | t.Cleanup(func() { 53 | t.Log("Removing MySQL container from Docker") 54 | if err := db.Terminate(ctx); err != nil { 55 | t.Errorf("failed to terminate MySQL container, err: %s", err) 56 | } 57 | }) 58 | 59 | host, err := db.Host(ctx) 60 | if err != nil { 61 | t.Fatalf("failed to get host where the container is exposed, err: %s", err) 62 | } 63 | 64 | port, err := db.MappedPort(ctx, "3306/tcp") 65 | if err != nil { 66 | t.Fatalf("failed to get externally mapped port to MySQL database, err: %s", err) 67 | } 68 | 69 | t.Log("Got connection port to MySQL: ", port) 70 | 71 | return &config.MySQLConfig{ 72 | User: user, 73 | Password: password, 74 | Host: host, 75 | Port: port.Int(), 76 | Database: dbName, 77 | CharSet: "utf8mb4", 78 | ParseTime: false, 79 | TimeZone: "UTC", 80 | } 81 | } 82 | 83 | func MockMySQLData(t *testing.T, conf *config.Config, sqls []string) *gorm.DB { 84 | err := migrate.MySQLMigrateDrop(conf) 85 | if err != nil { 86 | t.Fatalf("MySQLMigrateDrop fail, err: %+v\n", err) 87 | } 88 | 89 | err = migrate.MySQLMigrateUp(conf) 90 | if err != nil { 91 | t.Fatalf("MySQLMigrateUp fail %+v\n", err) 92 | } 93 | 94 | dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=%s&parseTime=%t&loc=%s", 95 | config.GlobalConfig.MySQL.User, 96 | config.GlobalConfig.MySQL.Password, 97 | config.GlobalConfig.MySQL.Host, 98 | config.GlobalConfig.MySQL.Port, 99 | config.GlobalConfig.MySQL.Database, 100 | config.GlobalConfig.MySQL.CharSet, 101 | config.GlobalConfig.MySQL.ParseTime, 102 | config.GlobalConfig.MySQL.TimeZone, 103 | ) 104 | dialect := driver.New(driver.Config{ 105 | DSN: dsn, 106 | DriverName: "mysql", 107 | SkipInitializeWithVersion: true, 108 | }) 109 | 110 | db, err := gorm.Open(dialect, &gorm.Config{}) 111 | if err != nil { 112 | log.Fatalf("failed to connect database: %v", err) 113 | } 114 | 115 | for _, sql := range sqls { 116 | tx := db.Exec(sql) 117 | if tx.Error != nil { 118 | t.Fatalf("Unable to insert data %+v\n", err) 119 | } 120 | } 121 | 122 | return db 123 | } 124 | -------------------------------------------------------------------------------- /tests/mysql_example_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | 8 | "go-hexagonal/config" 9 | ) 10 | 11 | func TestMockMySQLData(t *testing.T) { 12 | var testCases = []struct { 13 | Name string 14 | sqlData []string 15 | }{ 16 | { 17 | Name: "normal test", 18 | sqlData: []string{ 19 | "INSERT INTO users (id, name, email, uid) VALUES (1,'rancho', 'testing@gmail.com', 'abcdefghijklmnopqrstuvwxyz12')", 20 | }, 21 | }, 22 | } 23 | 24 | mysqlDBConf := SetupMySQL(t) 25 | config.GlobalConfig.MySQL = mysqlDBConf 26 | config.GlobalConfig.MigrationDir = "./migrations" 27 | 28 | for _, testcase := range testCases { 29 | t.Log("testing ", testcase.Name) 30 | 31 | db := MockMySQLData(t, config.GlobalConfig, testcase.sqlData) 32 | 33 | type UserVO struct { 34 | Id int 35 | Name string 36 | Email string 37 | } 38 | user := UserVO{} 39 | tx := db.Raw("SELECT name, email from users where id = ?", 1).Scan(&user) 40 | if tx.Error != nil { 41 | t.Errorf("query data fail: %v", tx.Error) 42 | } 43 | assert.Equal(t, "rancho", user.Name) 44 | assert.Equal(t, "testing@gmail.com", user.Email) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /tests/postgresql.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | "github.com/jackc/pgx/v5/pgxpool" 9 | "github.com/testcontainers/testcontainers-go" 10 | "github.com/testcontainers/testcontainers-go/wait" 11 | 12 | "go-hexagonal/adapter/repository/postgre" 13 | "go-hexagonal/config" 14 | "go-hexagonal/tests/migrations/migrate" 15 | ) 16 | 17 | func SetupPostgreSQL(t *testing.T) (postgreSQLConfig *config.PostgreSQLConfig) { 18 | t.Log("Setting up an instance of PostgreSQL with testcontainers-go") 19 | ctx := context.TODO() 20 | 21 | user, dbName, password := "postgres", "postgres", "123456" 22 | 23 | req := testcontainers.ContainerRequest{ 24 | Image: "postgres:latest", 25 | ExposedPorts: []string{"5432/tcp"}, 26 | Env: map[string]string{ 27 | "POSTGRES_USER": user, 28 | "POSTGRES_PASSWORD": password, 29 | "POSTGRES_DATABASE": dbName, 30 | }, 31 | WaitingFor: wait.ForAll( 32 | wait.ForListeningPort("5432/tcp"), 33 | wait.ForExec([]string{"pg_isready"}). 34 | WithPollInterval(1*time.Second). 35 | WithExitCodeMatcher(func(exitCode int) bool { 36 | return exitCode == 0 37 | }), 38 | wait.ForLog("database system is ready to accept connections"), 39 | ), 40 | } 41 | 42 | pg, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ 43 | ContainerRequest: req, 44 | Started: true, 45 | }) 46 | if err != nil { 47 | t.Fatalf("Could not start Docker container: %s", err) 48 | } 49 | 50 | // Clean up the container after the test is complete 51 | t.Cleanup(func() { 52 | t.Log("Removing pg container from Docker") 53 | if err := pg.Terminate(ctx); err != nil { 54 | t.Fatalf("failed to terminate pg container: %s", err) 55 | } 56 | }) 57 | 58 | host, err := pg.Host(ctx) 59 | if err != nil { 60 | t.Fatalf("Failed to get host where the container host is exposed: %s", err) 61 | } 62 | 63 | port, err := pg.MappedPort(ctx, "5432/tcp") 64 | if err != nil { 65 | t.Fatalf("Failed to get externally mapped port to pg database: %s", err) 66 | } 67 | t.Log("Got connection port to PostgreSQL: ", port) 68 | 69 | return &config.PostgreSQLConfig{ 70 | Host: host, 71 | Port: port.Int(), 72 | User: user, 73 | Password: password, 74 | Database: dbName, 75 | SSLMode: "disable", 76 | TimeZone: "UTC", 77 | } 78 | } 79 | 80 | func MockPgSQLData(t *testing.T, conf *config.Config, sqls []string) *pgxpool.Pool { 81 | err := migrate.PostgreMigrateDrop(conf) 82 | if err != nil { 83 | t.Fatalf("PostgreMigrateDrop fail %+v\n", err) 84 | } 85 | 86 | err = migrate.PostgreMigrateUp(conf) 87 | if err != nil { 88 | t.Fatalf("PostgreMigrateUp fail %+v\n", err) 89 | } 90 | 91 | pgPool, err := postgre.NewConnPool(conf.Postgre) 92 | if err != nil { 93 | t.Fatalf("NewConnPool fail %+v\n", err) 94 | } 95 | 96 | err = pgPool.Ping(context.Background()) 97 | if err != nil { 98 | t.Fatalf("pgPool Ping fail %+v\n", err) 99 | } 100 | 101 | for _, sql := range sqls { 102 | _, err = pgPool.Exec(context.Background(), sql) 103 | if err != nil { 104 | t.Fatalf("Unable to insert data %+v\n", err) 105 | } 106 | } 107 | 108 | return pgPool 109 | } 110 | -------------------------------------------------------------------------------- /tests/postgresql_example_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | 9 | "go-hexagonal/config" 10 | ) 11 | 12 | func TestMockPostgreSQLData(t *testing.T) { 13 | ctx := context.Background() 14 | 15 | var testCases = []struct { 16 | Name string 17 | pgsqlData []string 18 | }{ 19 | { 20 | Name: "normal test", 21 | pgsqlData: []string{ 22 | "INSERT INTO users (id, name, email, uid) VALUES (1,'rancho', 'testing@gmail.com', 'abcdefghijklmnopqrstuvwxyz12')", 23 | }, 24 | }, 25 | } 26 | 27 | postgresDBConf := SetupPostgreSQL(t) 28 | config.GlobalConfig.Postgre = postgresDBConf 29 | config.GlobalConfig.MigrationDir = "./migrations" 30 | 31 | for _, testcase := range testCases { 32 | t.Log("testing ", testcase.Name) 33 | 34 | pg := MockPgSQLData(t, config.GlobalConfig, testcase.pgsqlData) 35 | 36 | var name, email string 37 | err := pg.QueryRow(ctx, "SELECT name, email FROM users WHERE id = $1", 1).Scan(&name, &email) 38 | if err != nil { 39 | t.Errorf("query data fail: %v", err) 40 | } 41 | assert.Equal(t, name, "rancho") 42 | assert.Equal(t, email, "testing@gmail.com") 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /tests/redis.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "strconv" 5 | "testing" 6 | 7 | "github.com/alicebob/miniredis/v2" 8 | 9 | "go-hexagonal/config" 10 | ) 11 | 12 | func SetupRedis(t *testing.T) (redisConf *config.RedisConfig, s *miniredis.Miniredis) { 13 | s = miniredis.RunT(t) 14 | 15 | redisPort, err := strconv.Atoi(s.Port()) 16 | if err != nil { 17 | t.Fatalf("failed to get redis post, err: %s", err) 18 | } 19 | 20 | return &config.RedisConfig{ 21 | Host: s.Host(), 22 | Port: redisPort, 23 | }, s 24 | } 25 | 26 | func MockRedisData(t *testing.T, miniRedis *miniredis.Miniredis, data map[string]string) { 27 | miniRedis.FlushAll() 28 | 29 | for k, v := range data { 30 | err := miniRedis.Set(k, v) 31 | if err != nil { 32 | t.Fatalf("mock redis data fail, k: %s, v: %s, err: %s", k, v, err) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /tests/redis_example_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestMockRedisData(t *testing.T) { 10 | var testCases = []struct { 11 | Name string 12 | MockData map[string]string 13 | }{ 14 | { 15 | Name: "normal test", 16 | MockData: map[string]string{ 17 | "token": "mock-token", 18 | "name": "rancho", 19 | }, 20 | }, 21 | } 22 | 23 | _, miniRedis := SetupRedis(t) 24 | 25 | for _, testcase := range testCases { 26 | t.Log("testing ", testcase.Name) 27 | 28 | MockRedisData(t, miniRedis, testcase.MockData) 29 | 30 | token, err := miniRedis.Get("token") 31 | assert.NoError(t, err) 32 | assert.Equal(t, token, "mock-token") 33 | name, err := miniRedis.Get("name") 34 | assert.NoError(t, err) 35 | assert.Equal(t, name, "rancho") 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /util/clean_arch/clean_arch_test.go: -------------------------------------------------------------------------------- 1 | package clean_arch 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "go-hexagonal/config" 8 | "go-hexagonal/util" 9 | "go-hexagonal/util/log" 10 | ) 11 | 12 | var layersAliases = map[string]Layer{ 13 | // Domain 14 | "domain": LayerDomain, 15 | 16 | // Application 17 | "application": LayerApplication, 18 | 19 | // Interfaces 20 | "interface": LayerInterfaces, 21 | "api": LayerInterfaces, 22 | 23 | // Infrastructure 24 | "infrastructure": LayerInfrastructure, 25 | "adapter": LayerInfrastructure, 26 | } 27 | 28 | func TestValidator_Validate(t *testing.T) { 29 | config.Init("../../config", "config") 30 | log.Init() 31 | 32 | aliases := make(map[string]Layer) 33 | for alias, layer := range layersAliases { 34 | aliases[alias] = layer 35 | } 36 | 37 | ignoredPackages := []string{"cmd", "config", "util", "tests"} 38 | 39 | root := util.GetProjectRootPath() 40 | log.SugaredLogger.Infof("[Clean Arch] start checking, root: %s", root) 41 | 42 | validator := NewValidator(aliases) 43 | count, isValid, _, err := validator.Validate(root, true, ignoredPackages) 44 | if err != nil { 45 | panic(err) 46 | } 47 | 48 | log.SugaredLogger.Infof("[Clean Arch] scaned %d files", count) 49 | if isValid { 50 | log.Logger.Info("[Clean Arch] Good Job!") 51 | } else { 52 | log.Logger.Warn("[Clean Arch] your arch is not clean enough") 53 | } 54 | } 55 | 56 | func TestParseLayerMetadata(t *testing.T) { 57 | testCases := []struct { 58 | Path string 59 | ExpectedFileMetadata LayerMetadata 60 | }{ 61 | // domain layer 62 | { 63 | Path: "/go-hexagonal/domain/file.go", 64 | ExpectedFileMetadata: LayerMetadata{ 65 | Module: "domain", 66 | Layer: LayerDomain, 67 | }, 68 | }, 69 | { 70 | Path: "/go-hexagonal/domain/sub-package/file.go", 71 | ExpectedFileMetadata: LayerMetadata{ 72 | Module: "domain", 73 | Layer: LayerDomain, 74 | }, 75 | }, 76 | } 77 | 78 | for _, c := range testCases { 79 | t.Run(c.Path, func(t *testing.T) { 80 | metadata := ParseLayerMetadata(c.Path, layersAliases) 81 | 82 | if !reflect.DeepEqual(metadata, c.ExpectedFileMetadata) { 83 | t.Errorf("invalid metadata: %+v, expected %+v", metadata, c.ExpectedFileMetadata) 84 | } 85 | }) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /util/clean_arch/main_test.go: -------------------------------------------------------------------------------- 1 | package clean_arch 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "go.uber.org/zap" 8 | 9 | "go-hexagonal/util/log" 10 | ) 11 | 12 | func TestMain(m *testing.M) { 13 | // 初始化日志配置 14 | initTestLogger() 15 | 16 | // 运行测试 17 | exitCode := m.Run() 18 | 19 | // 退出 20 | os.Exit(exitCode) 21 | } 22 | 23 | // initTestLogger 初始化测试环境的日志配置 24 | func initTestLogger() { 25 | // 使用最简单的控制台日志配置 26 | logger, _ := zap.NewDevelopment() 27 | zap.ReplaceGlobals(logger) 28 | 29 | // 初始化全局日志变量 30 | log.Logger = logger 31 | log.SugaredLogger = logger.Sugar() 32 | } 33 | -------------------------------------------------------------------------------- /util/convert.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "strconv" 5 | ) 6 | 7 | // StringToInt converts a string to an integer 8 | func StringToInt(str string) (int, error) { 9 | return strconv.Atoi(str) 10 | } 11 | 12 | // StringToInt64 converts a string to an int64 13 | func StringToInt64(str string) (int64, error) { 14 | return strconv.ParseInt(str, 10, 64) 15 | } 16 | 17 | // StringToUint converts a string to an unsigned integer 18 | func StringToUint(str string) (uint, error) { 19 | val, err := strconv.ParseUint(str, 10, 64) 20 | if err != nil { 21 | return 0, err 22 | } 23 | return uint(val), nil 24 | } 25 | 26 | // StringToUint64 converts a string to an uint64 27 | func StringToUint64(str string) (uint64, error) { 28 | return strconv.ParseUint(str, 10, 64) 29 | } 30 | 31 | // StringToFloat64 converts a string to a float64 32 | func StringToFloat64(str string) (float64, error) { 33 | return strconv.ParseFloat(str, 64) 34 | } 35 | 36 | // StringToBool converts a string to a boolean 37 | func StringToBool(str string) (bool, error) { 38 | return strconv.ParseBool(str) 39 | } 40 | -------------------------------------------------------------------------------- /util/log/logger_usage_examples.go: -------------------------------------------------------------------------------- 1 | // Package log provides logging functionality for the application 2 | package log 3 | 4 | import ( 5 | "errors" 6 | "time" 7 | 8 | "go.uber.org/zap" 9 | ) 10 | 11 | // LoggerUsageExamples demonstrates the best practices for using Logger and SugaredLogger 12 | func LoggerUsageExamples() { 13 | // Sample variables for demonstration 14 | errDatabaseConnection := errors.New("connection refused") 15 | userID := "user456" 16 | ipAddress := "203.0.113.42" 17 | err := errors.New("invalid request parameters") 18 | 19 | // ===== LOGGER (Structured Logger) ===== 20 | // Best used for: 21 | // 1. Performance-critical code paths 22 | // 2. Structured data with known fields 23 | // 3. High-volume logging 24 | 25 | // Example 1: Basic structured logging with explicit fields 26 | Logger.Info("User logged in", 27 | zap.String("user_id", "user123"), 28 | zap.String("ip_address", "192.168.1.1"), 29 | zap.String("user_agent", "Mozilla/5.0"), 30 | ) 31 | 32 | // Example 2: Error logging with structured context 33 | Logger.Error("Database connection failed", 34 | zap.String("db_host", "db.example.com"), 35 | zap.Int("port", 5432), 36 | zap.Duration("timeout", 30*time.Second), 37 | zap.Error(errDatabaseConnection), 38 | ) 39 | 40 | // Example 3: Warn level with context fields 41 | Logger.Warn("Rate limit exceeded", 42 | zap.String("client_id", "client456"), 43 | zap.Int("limit", 100), 44 | zap.Int("current_rate", 120), 45 | ) 46 | 47 | // ===== SUGARED LOGGER ===== 48 | // Best used for: 49 | // 1. Debug/development logging 50 | // 2. Dynamic or variable number of arguments 51 | // 3. When convenience is more important than optimal performance 52 | 53 | // Example 1: Simple message with printf-style formatting 54 | SugaredLogger.Infof("Processing item %d of %d", 5, 10) 55 | 56 | // Example 2: Error with variable message 57 | SugaredLogger.Errorf("Failed to process request: %v", err) 58 | 59 | // Example 3: Using key-value pairs with .Infow, .Errorw, etc. 60 | SugaredLogger.Infow("Request processed", 61 | "method", "GET", 62 | "path", "/api/users", 63 | "status", 200, 64 | "duration_ms", 45.2, 65 | ) 66 | 67 | // Example 4: Warning with formatting 68 | SugaredLogger.Warnf("Unusual access pattern detected for user %s from IP %s", userID, ipAddress) 69 | } 70 | 71 | // General recommendation on when to use each: 72 | // 73 | // Use Logger (structured logger) when: 74 | // - You're logging in a hot code path (performance critical) 75 | // - You have a fixed set of fields to log 76 | // - You need maximum performance 77 | // - You're building core infrastructure or libraries 78 | // 79 | // Use SugaredLogger when: 80 | // - You're logging in non-performance-critical paths 81 | // - You need string formatting 82 | // - You have a variable number of fields 83 | // - You're writing application code where convenience is important 84 | // - You're doing temporary debugging 85 | -------------------------------------------------------------------------------- /util/util.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "path/filepath" 5 | "runtime" 6 | ) 7 | 8 | func GetCurrentPath() string { 9 | _, file, _, _ := runtime.Caller(1) 10 | 11 | return filepath.Dir(file) 12 | } 13 | 14 | func GetProjectRootPath() string { 15 | _, b, _, _ := runtime.Caller(0) 16 | 17 | return filepath.Join(filepath.Dir(b), "../") 18 | } 19 | --------------------------------------------------------------------------------