├── .gitignore ├── .golangci.yml ├── Makefile ├── Readme.md ├── docker-compose.yml ├── go.mod ├── go.sum └── src ├── customeraccounts ├── Acceptance_test.go ├── Benchmark_test.go ├── hexagon │ ├── ForChangingCustomerEmailAddresses.go │ ├── ForChangingCustomerNames.go │ ├── ForConfirmingCustomerEmailAddresses.go │ ├── ForDeletingCustomers.go │ ├── ForRegisteringCustomers.go │ ├── ForRetrievingCustomerViews.go │ └── application │ │ ├── CustomerCommandHandler.go │ │ ├── CustomerQueryHandler.go │ │ ├── ForAppendingToCustomerEventStreams.go │ │ ├── ForPurgingCustomerEventStreams.go │ │ ├── ForRetrievingCustomerEventStreams.go │ │ ├── ForStartingCustomerEventStreams.go │ │ └── domain │ │ ├── ChangeCustomerEmailAddress.go │ │ ├── ChangeCustomerName.go │ │ ├── ConfirmCustomerEmailAddress.go │ │ ├── CustomerDeleted.go │ │ ├── CustomerEmailAddressChanged.go │ │ ├── CustomerEmailAddressConfirmationFailed.go │ │ ├── CustomerEmailAddressConfirmed.go │ │ ├── CustomerNameChanged.go │ │ ├── CustomerRegistered.go │ │ ├── DeleteCustomer.go │ │ ├── RegisterCustomer.go │ │ └── customer │ │ ├── AssertUniqueEmailAddresses.go │ │ ├── ChangeEmailAddress.go │ │ ├── ChangeEmailAddress_test.go │ │ ├── ChangeName.go │ │ ├── ChangeName_test.go │ │ ├── ConfirmEmailAddress.go │ │ ├── ConfirmEmailAddress_test.go │ │ ├── Delete.go │ │ ├── Delete_test.go │ │ ├── Register.go │ │ ├── Register_test.go │ │ ├── View.go │ │ ├── assertNotDeleted.go │ │ ├── currentState.go │ │ └── value │ │ ├── ConfirmationHash.go │ │ ├── ConfirmedEmailAddress.go │ │ ├── CustomerID.go │ │ ├── EmailAddress.go │ │ ├── PersonName.go │ │ ├── PersonName_test.go │ │ └── UnconfirmedEmailAddress.go └── infrastructure │ ├── adapter │ ├── grpc │ │ ├── CustomerServer.go │ │ ├── CustomerServer_test.go │ │ ├── MapToGRPCErrors.go │ │ └── proto │ │ │ ├── customer.pb.go │ │ │ └── customer.proto │ ├── postgres │ │ ├── CustomerEventStore.go │ │ ├── UniqueCustomerEmailAddresses.go │ │ └── database │ │ │ ├── Migrator.go │ │ │ ├── migrations │ │ │ ├── 1_create_eventstore.up.sql │ │ │ └── 2_create_unique_email_addresses.up.sql │ │ │ └── setup │ │ │ └── init-db.sh │ └── rest │ │ ├── CustomHTTPError.go │ │ ├── customer.swagger.json │ │ └── proto │ │ └── customer.pb.gw.go │ └── serialization │ ├── CustomerEventJSONMapping.go │ ├── MarshalAndUnmarshalCustomerEvents_test.go │ ├── MarshalCustomerEvent.go │ └── UnmarshalCustomerEvent.go ├── service ├── grpc │ ├── Config.go │ ├── Config_test.go │ ├── DIContainer.go │ ├── DIContainer_test.go │ ├── InitPostgresDB.go │ ├── Service.go │ ├── Service_test.go │ └── cmd │ │ └── main.go └── rest │ ├── Config.go │ ├── Config_test.go │ ├── Service.go │ ├── Service_test.go │ └── cmd │ └── main.go └── shared ├── Errors.go ├── Logger.go ├── Logger_test.go ├── RetryOnConcurrencyConflict.go ├── RetryOnConcurrencyConflict_test.go └── es ├── DomainEvent.go ├── EventMeta.go ├── EventMetaForJSON.go ├── EventStore.go ├── EventStream.go ├── MarshalDomainEvent.go ├── MessageID.go ├── MessageID_test.go ├── RecordedEvents.go ├── StreamID.go ├── StreamID_test.go └── UnmarshalDomainEvent.go /.gitignore: -------------------------------------------------------------------------------- 1 | *.http 2 | *.env 3 | env.* -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | linters-settings: 2 | depguard: 3 | list-type: blacklist 4 | packages: 5 | # logging is allowed only by logutils.Log, logrus 6 | # is allowed to use only in logutils package 7 | - github.com/sirupsen/logrus 8 | packages-with-error-message: 9 | - github.com/sirupsen/logrus: "logging is allowed only by logutils.Log" 10 | dupl: 11 | threshold: 100 12 | funlen: 13 | lines: 100 14 | statements: 50 15 | gci: 16 | local-prefixes: github.com/golangci/golangci-lint 17 | goconst: 18 | min-len: 2 19 | min-occurrences: 2 20 | gocritic: 21 | enabled-tags: 22 | - diagnostic 23 | - experimental 24 | - opinionated 25 | - performance 26 | - style 27 | disabled-checks: 28 | - dupImport # https://github.com/go-critic/go-critic/issues/845 29 | - ifElseChain 30 | - octalLiteral 31 | - whyNoLint 32 | - wrapperFunc 33 | settings: 34 | hugeParam: 35 | sizeThreshold: 200 36 | 37 | gocyclo: 38 | min-complexity: 15 39 | goimports: 40 | local-prefixes: github.com/golangci/golangci-lint 41 | golint: 42 | min-confidence: 0 43 | gomnd: 44 | settings: 45 | mnd: 46 | # don't include the "operation" and "assign" 47 | checks: argument,case,condition,return 48 | govet: 49 | check-shadowing: true 50 | settings: 51 | printf: 52 | funcs: 53 | - (github.com/golangci/golangci-lint/pkg/logutils.Log).Infof 54 | - (github.com/golangci/golangci-lint/pkg/logutils.Log).Warnf 55 | - (github.com/golangci/golangci-lint/pkg/logutils.Log).Errorf 56 | - (github.com/golangci/golangci-lint/pkg/logutils.Log).Fatalf 57 | lll: 58 | line-length: 140 59 | maligned: 60 | suggest-new: true 61 | misspell: 62 | locale: US 63 | nolintlint: 64 | allow-leading-space: true # don't require machine-readable nolint directives (i.e. with no leading space) 65 | allow-unused: false # report any unused nolint directives 66 | require-explanation: false # don't require an explanation for nolint directives 67 | require-specific: false # don't require nolint directives to be specific about which linter is being skipped 68 | 69 | linters: 70 | # please, do not use `enable-all`: it's deprecated and will be removed soon. 71 | # inverted configuration with `enable-all` and `disable` is not scalable during updates of golangci-lint 72 | disable-all: true 73 | enable: 74 | - bodyclose 75 | - deadcode 76 | - depguard 77 | - dogsled 78 | - errcheck 79 | - exhaustive 80 | - funlen 81 | - gochecknoinits 82 | - goconst 83 | - gocritic 84 | - gocyclo 85 | - gofmt 86 | - goimports 87 | - golint 88 | - gomnd 89 | - goprintffuncname 90 | - gosec 91 | - gosimple 92 | - govet 93 | - ineffassign 94 | - lll 95 | - misspell 96 | - nakedret 97 | - noctx 98 | - nolintlint 99 | - rowserrcheck 100 | - scopelint 101 | - staticcheck 102 | - structcheck 103 | - stylecheck 104 | - typecheck 105 | - unconvert 106 | - unparam 107 | - unused 108 | - varcheck 109 | 110 | # don't enable: 111 | # - asciicheck 112 | # - gochecknoglobals 113 | # - gocognit 114 | # - godot 115 | # - godox 116 | # - goerr113 117 | # - maligned 118 | # - nestif 119 | # - prealloc 120 | # - testpackage 121 | # - wsl 122 | 123 | # disabled by Anton: 124 | # - dupl 125 | # - whitespace (I want an empty line after multipline function signatures) 126 | # - interfacer (deprecated) 127 | 128 | issues: 129 | # Excluding configuration per-path, per-linter, per-text and per-source 130 | exclude-rules: 131 | - path: _test\.go 132 | linters: 133 | - gomnd 134 | - lll 135 | - funlen 136 | - goconst 137 | 138 | # TODO temporary rule, must be removed 139 | # seems related to v0.34.1, but I was not able to reproduce locally, 140 | # I was also not able to reproduce in the CI of a fork, 141 | # only the golangci-lint CI seems to be affected by this invalid analysis. 142 | - path: pkg/golinters/scopelint.go 143 | text: 'directive `//nolint:interfacer` is unused for linter interfacer' 144 | 145 | run: 146 | skip-dirs: 147 | - test/testdata_etc 148 | - internal/cache 149 | - internal/renameio 150 | - internal/robustio 151 | 152 | # golangci.com configuration 153 | # https://github.com/golangci/golangci/wiki/Configuration 154 | service: 155 | golangci-lint-version: 1.23.x # use the fixed version to not introduce new linters unexpectedly 156 | prepare: 157 | - echo "here I can run custom commands, but no preparation needed for this repo" -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | GRPC_GATEWAY_DIR := $(shell go list -f '{{ .Dir }}' -m github.com/grpc-ecosystem/grpc-gateway 2> /dev/null) 2 | GO_MODULE := $(shell go mod edit -json | grep Path | head -n 1 | cut -d ":" -f 2 | cut -d '"' -f 2) 3 | PROTO_DIR := src/customeraccounts/infrastructure/adapter/grpc/proto 4 | GRPC_TARGET_DIR := src/customeraccounts/infrastructure/adapter/grpc/proto 5 | REST_GW_TARGET_DIR := src/customeraccounts/infrastructure/adapter/rest/proto 6 | REST_GW_OUT_FILE := customer.pb.gw.go 7 | REST_SWAGGER_TARGET_DIR := src/customeraccounts/infrastructure/adapter/rest 8 | 9 | generate_proto: 10 | @protoc \ 11 | -I $(GRPC_TARGET_DIR) \ 12 | -I /usr/local/include \ 13 | -I $(GRPC_GATEWAY_DIR)/third_party/googleapis \ 14 | --go_out=plugins=grpc:$(GRPC_TARGET_DIR) \ 15 | --grpc-gateway_out=logtostderr=true,import_path=customerrest:$(REST_GW_TARGET_DIR) \ 16 | --swagger_out=logtostderr=true:$(REST_SWAGGER_TARGET_DIR) \ 17 | $(PROTO_DIR)/customer.proto 18 | 19 | @# Not possible to split grpc and rest otherwise: https://github.com/grpc-ecosystem/grpc-gateway/issues/353 20 | @sed -i '/package customerrest/ a \\nimport customergrpcproto "$(GO_MODULE)/$(GRPC_TARGET_DIR)"' $(REST_GW_TARGET_DIR)/$(REST_GW_OUT_FILE) 21 | @sed -i 's/client CustomerClient/client customergrpcproto.CustomerClient/' $(REST_GW_TARGET_DIR)/$(REST_GW_OUT_FILE) 22 | @sed -i 's/server CustomerServer/server customergrpcproto.CustomerServer/' $(REST_GW_TARGET_DIR)/$(REST_GW_OUT_FILE) 23 | @sed -i 's/NewCustomerClient/customergrpcproto.NewCustomerClient/' $(REST_GW_TARGET_DIR)/$(REST_GW_OUT_FILE) 24 | @sed -i -E 's/var protoReq (.+)/var protoReq customergrpcproto.\1/' $(REST_GW_TARGET_DIR)/$(REST_GW_OUT_FILE) 25 | 26 | lint: 27 | golangci-lint run --build-tags test ./... 28 | 29 | # https://github.com/golangci/golangci-lint 30 | install-golangci-lint: 31 | curl -sfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh| sh -s -- -b $(shell go env GOPATH)/bin v1.36.0 32 | 33 | 34 | # https://github.com/psampaz/go-mod-outdated 35 | outdated-list: 36 | go list -u -m -json all | go-mod-outdated -update -direct -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # A sample project about implementing Domain-Driven Design and Hexagonal Architecture (Ports&Adapters) with Go! 2 | 3 | This code is the basis for a series of blog posts: 4 | 5 | [Implementing Domain-Driven Design and Hexagonal Architecture with Go - Part 1](https://medium.com/@TonyBologni/implementing-domain-driven-design-and-hexagonal-architecture-with-go-1-292938c0a4d4) 6 | 7 | [Implementing Domain-Driven Design and Hexagonal Architecture with Go - Part 2](https://medium.com/@TonyBologni/implementing-domain-driven-design-and-hexagonal-architecture-with-go-2-efd432505554) 8 | 9 | Part 3 (about Hexagonal Architecture) is ongoing 10 | 11 | The code those blog posts are about is frozen in this branch: [freeze_blog-posts_1-3](https://github.com/AntonStoeckl/go-iddd/tree/freeze_blog-posts_1-3) 12 | 13 | ### Setup for local development 14 | 15 | #### Start Docker container(s) 16 | 17 | Run `docker-compose up -d` in the project root. 18 | 19 | #### Environment configuration 20 | 21 | ##### To be able to start the service 22 | 23 | Create local.env file in the project root (.env files is gitignored there) with following contents and replace 24 | $PathToProjectRoot$ with the path to the root of the go-iddd project sources. 25 | 26 | ``` 27 | POSTGRES_DSN=postgresql://goiddd:password123@localhost:15432/goiddd_local?sslmode=disable 28 | POSTGRES_MIGRATIONS_PATH_CUSTOMER=$PathToProjectRoot$/go-iddd/service/customeraccounts/infrastructure/postgres/database/migrations 29 | GRPC_HOST_AND_PORT=localhost:5566 30 | REST_HOST_AND_PORT=localhost:8085 31 | REST_GRPC_DIAL_TIMEOUT=3 32 | SWAGGER_FILE_PATH_CUSTOMER=$PathToProjectRoot$/go-iddd/src/customeraccounts/infrastructure/adapter/rest 33 | ``` 34 | 35 | ##### To be able to run the tests 36 | 37 | Create test.env file in the project root (.env files is gitignored there) with following contents and replace 38 | $PathToProjectRoot$ with the path to the root of the go-iddd project sources. 39 | 40 | ``` 41 | POSTGRES_DSN=postgresql://goiddd:password123@localhost:15432/goiddd_test?sslmode=disable 42 | POSTGRES_MIGRATIONS_PATH_CUSTOMER=$PathToProjectRoot$/go-iddd/src/customeraccounts/infrastructure/adapter/postgres/database/migrations 43 | GRPC_HOST_AND_PORT=localhost:5566 44 | REST_HOST_AND_PORT=localhost:8085 45 | REST_GRPC_DIAL_TIMEOUT=3 46 | SWAGGER_FILE_PATH_CUSTOMER=$PathToProjectRoot$/go-iddd/src/customeraccounts/infrastructure/adapter/rest 47 | ``` 48 | 49 | ##### To run HTTP requests with GoLand's (IntelliJ) new built-in HTTP client 50 | 51 | Create a customer.http file in the project root (.http files are gitignored there) with following contents. 52 | 53 | ``` 54 | ### Register a Customer 55 | POST http://localhost:8085/v1/customer 56 | Accept: */* 57 | Cache-Control: no-cache 58 | Content-Type: application/json 59 | 60 | { 61 | "emailAddress": "john@doe.com", 62 | "familyName": "Doe", 63 | "givenName": "John" 64 | } 65 | 66 | > {% client.global.set("id", response.body.id); %} 67 | 68 | ### Confirm a Customer's email address 69 | PUT http://localhost:8085/v1/customer/{{id}}/emailaddress/confirm 70 | Accept: */* 71 | Cache-Control: no-cache 72 | Content-Type: application/json 73 | 74 | { 75 | "confirmationHash": "0acf14bbeaf0b9c6ef8e39d7f9254336" 76 | } 77 | 78 | ### Change a Customer's email address 79 | PUT http://localhost:8085/v1/customer/{{id}}/emailaddress 80 | Accept: */* 81 | Cache-Control: no-cache 82 | Content-Type: application/json 83 | 84 | { 85 | "emailAddress": "john+changed@doe.com" 86 | } 87 | 88 | ### Change a Customer's name 89 | PUT http://localhost:8085/v1/customer/{{id}}/name 90 | Accept: application/json 91 | Cache-Control: no-cache 92 | Content-Type: application/json 93 | 94 | { 95 | "givenName": "Joana", 96 | "familyName": "Doe" 97 | } 98 | 99 | ### Delete a Customer 100 | DELETE http://localhost:8085/v1/customer/{{id}} 101 | Accept: application/json 102 | Cache-Control: no-cache 103 | Content-Type: application/json 104 | 105 | ### Retrieve a Customer View 106 | GET http://localhost:8085/v1/customer/{{id}} 107 | Accept: application/json 108 | Cache-Control: no-cache 109 | Content-Type: application/json 110 | 111 | ### Get the Swagger documentation 112 | GET http://localhost:8085/v1/customer/swagger.json 113 | 114 | ### 115 | ``` 116 | 117 | **Attention** 118 | 119 | The *ConfirmEmailAddress* request does not work without changes - the *confirmationHash* needs to be adapted. 120 | You can find it in the *CustomerRegistered* event in the eventstore DB table. 121 | For security reasons the response of the *Register* request does not return the hash (it **must** only be sent to the Customer via email ;-) 122 | 123 | #### Start the service (gRPC and REST) 124 | 125 | ##### Via Terminal 126 | 127 | 1) Source the local.env file in your terminal, e.g. `source dev/local.env` or set the env vars in a different way 128 | 2) In the project root run `go run service/cmd/grpc/main.go` 129 | 130 | ##### Via GoLand 131 | 132 | 1) Create a build configuration for `service/cmd/grpc/main.go` 133 | 2) I suggest using the [EnvFile](https://plugins.jetbrains.com/plugin/7861-envfile) GoLand plugin 134 | and add the local.env file in the build configuration 135 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | 3 | services: 4 | 5 | postgres: 6 | image: postgres:11 7 | environment: 8 | POSTGRES_USER: postgres 9 | POSTGRES_PASSWORD: postgres 10 | GOIDDD_USERNAME: goiddd 11 | GOIDDD_PASSWORD: password123 12 | GOIDDD_LOCAL_DATABASE: goiddd_local 13 | GOIDDD_TEST_DATABASE: goiddd_test 14 | volumes: 15 | - ./src/customeraccounts/infrastructure/adapter/postgres/database/setup:/docker-entrypoint-initdb.d 16 | ports: 17 | - "15432:5432" -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/AntonStoeckl/go-iddd 2 | 3 | go 1.16 4 | 5 | require ( 6 | github.com/cockroachdb/errors v1.8.2 7 | github.com/cockroachdb/redact v1.0.9 // indirect 8 | github.com/go-resty/resty/v2 v2.5.0 9 | github.com/gogo/protobuf v1.3.2 // indirect 10 | github.com/golang-migrate/migrate/v4 v4.14.1 11 | github.com/golang/protobuf v1.4.3 12 | github.com/google/uuid v1.2.0 13 | github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00 // indirect 14 | github.com/grpc-ecosystem/grpc-gateway v1.16.0 15 | github.com/hashicorp/errwrap v1.1.0 // indirect 16 | github.com/json-iterator/go v1.1.10 17 | github.com/kr/pretty v0.2.1 // indirect 18 | github.com/kr/text v0.2.0 // indirect 19 | github.com/lib/pq v1.10.0 20 | github.com/rs/zerolog v1.20.0 21 | github.com/smartystreets/assertions v1.0.1 // indirect 22 | github.com/smartystreets/goconvey v1.6.4 23 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110 // indirect 24 | golang.org/x/sys v0.0.0-20210309074719-68d13333faf2 // indirect 25 | golang.org/x/text v0.3.5 // indirect 26 | google.golang.org/genproto v0.0.0-20210303154014-9728d6b83eeb 27 | google.golang.org/grpc v1.36.0 28 | ) 29 | -------------------------------------------------------------------------------- /src/customeraccounts/Benchmark_test.go: -------------------------------------------------------------------------------- 1 | package customeraccounts_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/AntonStoeckl/go-iddd/src/customeraccounts/hexagon/application" 7 | "github.com/AntonStoeckl/go-iddd/src/customeraccounts/hexagon/application/domain/customer/value" 8 | "github.com/AntonStoeckl/go-iddd/src/customeraccounts/infrastructure/adapter/postgres" 9 | "github.com/AntonStoeckl/go-iddd/src/service/grpc" 10 | "github.com/AntonStoeckl/go-iddd/src/shared" 11 | ) 12 | 13 | type benchmarkTestValues struct { 14 | customerID value.CustomerID 15 | emailAddress string 16 | givenName string 17 | familyName string 18 | newEmailAddress string 19 | newGivenName string 20 | newFamilyName string 21 | } 22 | 23 | func BenchmarkCustomerCommand(b *testing.B) { 24 | var err error 25 | 26 | logger := shared.NewNilLogger() 27 | config := grpc.MustBuildConfigFromEnv(logger) 28 | postgresDBConn := grpc.MustInitPostgresDB(config, logger) 29 | diContainer := grpc.MustBuildDIContainer(config, logger, grpc.UsePostgresDBConn(postgresDBConn)) 30 | commandHandler := diContainer.GetCustomerCommandHandler() 31 | v := initBenchmarkTestValues() 32 | prepareForBenchmark(b, commandHandler, &v) 33 | 34 | b.Run("ChangeName", func(b *testing.B) { 35 | for n := 0; n < b.N; n++ { 36 | if n%2 == 0 { 37 | if err = commandHandler.ChangeCustomerName(v.customerID.String(), v.newGivenName, v.newFamilyName); err != nil { 38 | b.FailNow() 39 | } 40 | } else { 41 | if err = commandHandler.ChangeCustomerName(v.customerID.String(), v.givenName, v.familyName); err != nil { 42 | b.FailNow() 43 | } 44 | } 45 | } 46 | }) 47 | 48 | cleanUpAfterBenchmark( 49 | b, 50 | diContainer.GetCustomerEventStore(), 51 | commandHandler, 52 | v.customerID, 53 | ) 54 | } 55 | 56 | func BenchmarkCustomerQuery(b *testing.B) { 57 | logger := shared.NewNilLogger() 58 | config := grpc.MustBuildConfigFromEnv(logger) 59 | postgresDBConn := grpc.MustInitPostgresDB(config, logger) 60 | diContainer := grpc.MustBuildDIContainer(config, logger, grpc.UsePostgresDBConn(postgresDBConn)) 61 | commandHandler := diContainer.GetCustomerCommandHandler() 62 | queryHandler := diContainer.GetCustomerQueryHandler() 63 | v := initBenchmarkTestValues() 64 | prepareForBenchmark(b, commandHandler, &v) 65 | 66 | b.Run("CustomerViewByID", func(b *testing.B) { 67 | for n := 0; n < b.N; n++ { 68 | if _, err := queryHandler.CustomerViewByID(v.customerID.String()); err != nil { 69 | b.FailNow() 70 | } 71 | } 72 | }) 73 | 74 | cleanUpAfterBenchmark( 75 | b, 76 | diContainer.GetCustomerEventStore(), 77 | commandHandler, 78 | v.customerID, 79 | ) 80 | } 81 | 82 | func initBenchmarkTestValues() benchmarkTestValues { 83 | var v benchmarkTestValues 84 | 85 | v.emailAddress = "fiona@gallagher.net" 86 | v.givenName = "Fiona" 87 | v.familyName = "Galagher" 88 | v.newEmailAddress = "fiona@pratt.net" 89 | v.newGivenName = "Fiona" 90 | v.newFamilyName = "Pratt" 91 | 92 | return v 93 | } 94 | 95 | func prepareForBenchmark( 96 | b *testing.B, 97 | commandHandler *application.CustomerCommandHandler, 98 | v *benchmarkTestValues, 99 | ) { 100 | 101 | var err error 102 | 103 | v.customerID = value.GenerateCustomerID() 104 | 105 | if err = commandHandler.RegisterCustomer(v.customerID, v.emailAddress, v.givenName, v.familyName); err != nil { 106 | b.FailNow() 107 | } 108 | 109 | for n := 0; n < 100; n++ { 110 | if n%2 == 0 { 111 | if err = commandHandler.ChangeCustomerEmailAddress(v.customerID.String(), v.newEmailAddress); err != nil { 112 | b.FailNow() 113 | } 114 | } else { 115 | if err = commandHandler.ChangeCustomerEmailAddress(v.customerID.String(), v.emailAddress); err != nil { 116 | b.FailNow() 117 | } 118 | } 119 | } 120 | } 121 | 122 | func cleanUpAfterBenchmark( 123 | b *testing.B, 124 | eventstore *postgres.CustomerEventStore, 125 | commandHandler *application.CustomerCommandHandler, 126 | id value.CustomerID, 127 | ) { 128 | 129 | if err := commandHandler.DeleteCustomer(id.String()); err != nil { 130 | b.FailNow() 131 | } 132 | 133 | if err := eventstore.PurgeEventStream(id); err != nil { 134 | b.FailNow() 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/customeraccounts/hexagon/ForChangingCustomerEmailAddresses.go: -------------------------------------------------------------------------------- 1 | package hexagon 2 | 3 | type ForChangingCustomerEmailAddresses func(customerID, emailAddress string) error 4 | -------------------------------------------------------------------------------- /src/customeraccounts/hexagon/ForChangingCustomerNames.go: -------------------------------------------------------------------------------- 1 | package hexagon 2 | 3 | type ForChangingCustomerNames func(customerID, givenName, familyName string) error 4 | -------------------------------------------------------------------------------- /src/customeraccounts/hexagon/ForConfirmingCustomerEmailAddresses.go: -------------------------------------------------------------------------------- 1 | package hexagon 2 | 3 | type ForConfirmingCustomerEmailAddresses func(customerID, confirmationHash string) error 4 | -------------------------------------------------------------------------------- /src/customeraccounts/hexagon/ForDeletingCustomers.go: -------------------------------------------------------------------------------- 1 | package hexagon 2 | 3 | type ForDeletingCustomers func(customerID string) error 4 | -------------------------------------------------------------------------------- /src/customeraccounts/hexagon/ForRegisteringCustomers.go: -------------------------------------------------------------------------------- 1 | package hexagon 2 | 3 | import "github.com/AntonStoeckl/go-iddd/src/customeraccounts/hexagon/application/domain/customer/value" 4 | 5 | type ForRegisteringCustomers func(customerIDValue value.CustomerID, emailAddress, givenName, familyName string) error 6 | -------------------------------------------------------------------------------- /src/customeraccounts/hexagon/ForRetrievingCustomerViews.go: -------------------------------------------------------------------------------- 1 | package hexagon 2 | 3 | import ( 4 | "github.com/AntonStoeckl/go-iddd/src/customeraccounts/hexagon/application/domain/customer" 5 | ) 6 | 7 | type ForRetrievingCustomerViews func(customerID string) (customer.View, error) 8 | -------------------------------------------------------------------------------- /src/customeraccounts/hexagon/application/CustomerCommandHandler.go: -------------------------------------------------------------------------------- 1 | package application 2 | 3 | import ( 4 | "github.com/AntonStoeckl/go-iddd/src/customeraccounts/hexagon/application/domain" 5 | "github.com/AntonStoeckl/go-iddd/src/customeraccounts/hexagon/application/domain/customer" 6 | "github.com/AntonStoeckl/go-iddd/src/customeraccounts/hexagon/application/domain/customer/value" 7 | "github.com/AntonStoeckl/go-iddd/src/shared" 8 | "github.com/cockroachdb/errors" 9 | ) 10 | 11 | const maxCustomerCommandHandlerRetries = uint8(10) 12 | 13 | type CustomerCommandHandler struct { 14 | retrieveCustomerEventStream ForRetrievingCustomerEventStreams 15 | startCustomerEventStream ForStartingCustomerEventStreams 16 | appendToCustomerEventStream ForAppendingToCustomerEventStreams 17 | } 18 | 19 | func NewCustomerCommandHandler( 20 | retrieveCustomerEventStream ForRetrievingCustomerEventStreams, 21 | startCustomerEventStream ForStartingCustomerEventStreams, 22 | appendToCustomerEventStream ForAppendingToCustomerEventStreams, 23 | ) *CustomerCommandHandler { 24 | 25 | return &CustomerCommandHandler{ 26 | retrieveCustomerEventStream: retrieveCustomerEventStream, 27 | startCustomerEventStream: startCustomerEventStream, 28 | appendToCustomerEventStream: appendToCustomerEventStream, 29 | } 30 | } 31 | 32 | func (h *CustomerCommandHandler) RegisterCustomer( 33 | customerIDValue value.CustomerID, 34 | emailAddress string, 35 | givenName string, 36 | familyName string, 37 | ) error { 38 | 39 | wrapWithMsg := "CustomerCommandHandler.RegisterCustomer" 40 | 41 | emailAddressValue, err := value.BuildUnconfirmedEmailAddress(emailAddress) 42 | if err != nil { 43 | return errors.Wrap(err, wrapWithMsg) 44 | } 45 | 46 | personNameValue, err := value.BuildPersonName(givenName, familyName) 47 | if err != nil { 48 | return errors.Wrap(err, wrapWithMsg) 49 | } 50 | 51 | command := domain.BuildRegisterCustomer( 52 | customerIDValue, 53 | emailAddressValue, 54 | personNameValue, 55 | ) 56 | 57 | doRegister := func() error { 58 | customerRegistered := customer.Register(command) 59 | 60 | if err := h.startCustomerEventStream(customerRegistered); err != nil { 61 | return err 62 | } 63 | 64 | return nil 65 | } 66 | 67 | if err := shared.RetryOnConcurrencyConflict(doRegister, maxCustomerCommandHandlerRetries); err != nil { 68 | return errors.Wrap(err, wrapWithMsg) 69 | } 70 | 71 | return nil 72 | } 73 | 74 | func (h *CustomerCommandHandler) ConfirmCustomerEmailAddress( 75 | customerID string, 76 | confirmationHash string, 77 | ) error { 78 | 79 | wrapWithMsg := "CustomerCommandHandler.ConfirmCustomerEmailAddress" 80 | 81 | customerIDValue, err := value.BuildCustomerID(customerID) 82 | if err != nil { 83 | return errors.Wrap(err, wrapWithMsg) 84 | } 85 | 86 | confirmationHashValue, err := value.BuildConfirmationHash(confirmationHash) 87 | if err != nil { 88 | return errors.Wrap(err, wrapWithMsg) 89 | } 90 | 91 | command := domain.BuildConfirmCustomerEmailAddress( 92 | customerIDValue, 93 | confirmationHashValue, 94 | ) 95 | 96 | doConfirmEmailAddress := func() error { 97 | eventStream, err := h.retrieveCustomerEventStream(command.CustomerID()) 98 | if err != nil { 99 | return err 100 | } 101 | 102 | recordedEvents, err := customer.ConfirmEmailAddress(eventStream, command) 103 | if err != nil { 104 | return err 105 | } 106 | 107 | if err := h.appendToCustomerEventStream(recordedEvents, command.CustomerID()); err != nil { 108 | return err 109 | } 110 | 111 | for _, event := range recordedEvents { 112 | if isError := event.IsFailureEvent(); isError { 113 | return event.FailureReason() 114 | } 115 | } 116 | 117 | return nil 118 | } 119 | 120 | if err := shared.RetryOnConcurrencyConflict(doConfirmEmailAddress, maxCustomerCommandHandlerRetries); err != nil { 121 | return errors.Wrap(err, wrapWithMsg) 122 | } 123 | 124 | return nil 125 | } 126 | 127 | func (h *CustomerCommandHandler) ChangeCustomerEmailAddress( 128 | customerID string, 129 | emailAddress string, 130 | ) error { 131 | 132 | wrapWithMsg := "CustomerCommandHandler.ChangeCustomerEmailAddress" 133 | 134 | customerIDValue, err := value.BuildCustomerID(customerID) 135 | if err != nil { 136 | return errors.Wrap(err, wrapWithMsg) 137 | } 138 | 139 | emailAddressValue, err := value.BuildUnconfirmedEmailAddress(emailAddress) 140 | if err != nil { 141 | return errors.Wrap(err, wrapWithMsg) 142 | } 143 | 144 | command := domain.BuildChangeCustomerEmailAddress( 145 | customerIDValue, 146 | emailAddressValue, 147 | ) 148 | 149 | doChangeEmailAddress := func() error { 150 | eventStream, err := h.retrieveCustomerEventStream(command.CustomerID()) 151 | if err != nil { 152 | return err 153 | } 154 | 155 | recordedEvents, err := customer.ChangeEmailAddress(eventStream, command) 156 | if err != nil { 157 | return err 158 | } 159 | 160 | if err := h.appendToCustomerEventStream(recordedEvents, command.CustomerID()); err != nil { 161 | return err 162 | } 163 | 164 | return nil 165 | } 166 | 167 | if err := shared.RetryOnConcurrencyConflict(doChangeEmailAddress, maxCustomerCommandHandlerRetries); err != nil { 168 | return errors.Wrap(err, wrapWithMsg) 169 | } 170 | 171 | return nil 172 | } 173 | 174 | func (h *CustomerCommandHandler) ChangeCustomerName( 175 | customerID string, 176 | givenName string, 177 | familyName string, 178 | ) error { 179 | 180 | wrapWithMsg := "CustomerCommandHandler.ChangeCustomerName" 181 | 182 | customerIDValue, err := value.BuildCustomerID(customerID) 183 | if err != nil { 184 | return errors.Wrap(err, wrapWithMsg) 185 | } 186 | 187 | personNameValue, err := value.BuildPersonName(givenName, familyName) 188 | if err != nil { 189 | return errors.Wrap(err, wrapWithMsg) 190 | } 191 | 192 | command := domain.BuildChangeCustomerName( 193 | customerIDValue, 194 | personNameValue, 195 | ) 196 | 197 | doChangeName := func() error { 198 | eventStream, err := h.retrieveCustomerEventStream(command.CustomerID()) 199 | if err != nil { 200 | return err 201 | } 202 | 203 | recordedEvents, err := customer.ChangeName(eventStream, command) 204 | if err != nil { 205 | return err 206 | } 207 | 208 | if err := h.appendToCustomerEventStream(recordedEvents, command.CustomerID()); err != nil { 209 | return err 210 | } 211 | 212 | return nil 213 | } 214 | 215 | if err := shared.RetryOnConcurrencyConflict(doChangeName, maxCustomerCommandHandlerRetries); err != nil { 216 | return errors.Wrap(err, wrapWithMsg) 217 | } 218 | 219 | return nil 220 | } 221 | 222 | func (h *CustomerCommandHandler) DeleteCustomer(customerID string) error { 223 | wrapWithMsg := "customerCommandHandler.DeleteCustomer" 224 | 225 | customerIDValue, err := value.BuildCustomerID(customerID) 226 | if err != nil { 227 | return errors.Wrap(err, wrapWithMsg) 228 | } 229 | 230 | command := domain.BuildDeleteCustomer(customerIDValue) 231 | 232 | doDelete := func() error { 233 | eventStream, err := h.retrieveCustomerEventStream(command.CustomerID()) 234 | if err != nil { 235 | return err 236 | } 237 | 238 | recordedEvents := customer.Delete(eventStream, command) 239 | 240 | if err := h.appendToCustomerEventStream(recordedEvents, command.CustomerID()); err != nil { 241 | return err 242 | } 243 | 244 | return nil 245 | } 246 | 247 | if err := shared.RetryOnConcurrencyConflict(doDelete, maxCustomerCommandHandlerRetries); err != nil { 248 | return errors.Wrap(err, wrapWithMsg) 249 | } 250 | 251 | return nil 252 | } 253 | -------------------------------------------------------------------------------- /src/customeraccounts/hexagon/application/CustomerQueryHandler.go: -------------------------------------------------------------------------------- 1 | package application 2 | 3 | import ( 4 | "github.com/AntonStoeckl/go-iddd/src/customeraccounts/hexagon/application/domain/customer" 5 | "github.com/AntonStoeckl/go-iddd/src/customeraccounts/hexagon/application/domain/customer/value" 6 | "github.com/AntonStoeckl/go-iddd/src/shared" 7 | "github.com/cockroachdb/errors" 8 | ) 9 | 10 | type CustomerQueryHandler struct { 11 | retrieveCustomerEventStream ForRetrievingCustomerEventStreams 12 | } 13 | 14 | func NewCustomerQueryHandler(retrieveCustomerEventStream ForRetrievingCustomerEventStreams) *CustomerQueryHandler { 15 | return &CustomerQueryHandler{ 16 | retrieveCustomerEventStream: retrieveCustomerEventStream, 17 | } 18 | } 19 | 20 | func (h *CustomerQueryHandler) CustomerViewByID(customerID string) (customer.View, error) { 21 | var err error 22 | var customerIDValue value.CustomerID 23 | wrapWithMsg := "customerQueryHandler.CustomerViewByID" 24 | 25 | if customerIDValue, err = value.BuildCustomerID(customerID); err != nil { 26 | return customer.View{}, errors.Wrap(err, wrapWithMsg) 27 | } 28 | 29 | eventStream, err := h.retrieveCustomerEventStream(customerIDValue) 30 | if err != nil { 31 | return customer.View{}, errors.Wrap(err, wrapWithMsg) 32 | } 33 | 34 | customerView := customer.BuildViewFrom(eventStream) 35 | 36 | if customerView.IsDeleted { 37 | err := errors.New("customer not found") 38 | 39 | return customer.View{}, shared.MarkAndWrapError(err, shared.ErrNotFound, wrapWithMsg) 40 | } 41 | 42 | return customerView, nil 43 | } 44 | -------------------------------------------------------------------------------- /src/customeraccounts/hexagon/application/ForAppendingToCustomerEventStreams.go: -------------------------------------------------------------------------------- 1 | package application 2 | 3 | import ( 4 | "github.com/AntonStoeckl/go-iddd/src/customeraccounts/hexagon/application/domain/customer/value" 5 | "github.com/AntonStoeckl/go-iddd/src/shared/es" 6 | ) 7 | 8 | type ForAppendingToCustomerEventStreams func(recordedEvents es.RecordedEvents, id value.CustomerID) error 9 | -------------------------------------------------------------------------------- /src/customeraccounts/hexagon/application/ForPurgingCustomerEventStreams.go: -------------------------------------------------------------------------------- 1 | package application 2 | 3 | import "github.com/AntonStoeckl/go-iddd/src/customeraccounts/hexagon/application/domain/customer/value" 4 | 5 | type ForPurgingCustomerEventStreams func(id value.CustomerID) error 6 | -------------------------------------------------------------------------------- /src/customeraccounts/hexagon/application/ForRetrievingCustomerEventStreams.go: -------------------------------------------------------------------------------- 1 | package application 2 | 3 | import ( 4 | "github.com/AntonStoeckl/go-iddd/src/customeraccounts/hexagon/application/domain/customer/value" 5 | "github.com/AntonStoeckl/go-iddd/src/shared/es" 6 | ) 7 | 8 | type ForRetrievingCustomerEventStreams func(id value.CustomerID) (es.EventStream, error) 9 | -------------------------------------------------------------------------------- /src/customeraccounts/hexagon/application/ForStartingCustomerEventStreams.go: -------------------------------------------------------------------------------- 1 | package application 2 | 3 | import ( 4 | "github.com/AntonStoeckl/go-iddd/src/customeraccounts/hexagon/application/domain" 5 | ) 6 | 7 | type ForStartingCustomerEventStreams func(customerRegistered domain.CustomerRegistered) error 8 | -------------------------------------------------------------------------------- /src/customeraccounts/hexagon/application/domain/ChangeCustomerEmailAddress.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import ( 4 | "github.com/AntonStoeckl/go-iddd/src/customeraccounts/hexagon/application/domain/customer/value" 5 | "github.com/AntonStoeckl/go-iddd/src/shared/es" 6 | ) 7 | 8 | type ChangeCustomerEmailAddress struct { 9 | customerID value.CustomerID 10 | emailAddress value.UnconfirmedEmailAddress 11 | messageID es.MessageID 12 | } 13 | 14 | func BuildChangeCustomerEmailAddress( 15 | customerID value.CustomerID, 16 | emailAddress value.UnconfirmedEmailAddress, 17 | ) ChangeCustomerEmailAddress { 18 | 19 | changeEmailAddress := ChangeCustomerEmailAddress{ 20 | customerID: customerID, 21 | emailAddress: emailAddress, 22 | messageID: es.GenerateMessageID(), 23 | } 24 | 25 | return changeEmailAddress 26 | } 27 | 28 | func (command ChangeCustomerEmailAddress) CustomerID() value.CustomerID { 29 | return command.customerID 30 | } 31 | 32 | func (command ChangeCustomerEmailAddress) EmailAddress() value.UnconfirmedEmailAddress { 33 | return command.emailAddress 34 | } 35 | 36 | func (command ChangeCustomerEmailAddress) MessageID() es.MessageID { 37 | return command.messageID 38 | } 39 | -------------------------------------------------------------------------------- /src/customeraccounts/hexagon/application/domain/ChangeCustomerName.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import ( 4 | "github.com/AntonStoeckl/go-iddd/src/customeraccounts/hexagon/application/domain/customer/value" 5 | "github.com/AntonStoeckl/go-iddd/src/shared/es" 6 | ) 7 | 8 | type ChangeCustomerName struct { 9 | customerID value.CustomerID 10 | personName value.PersonName 11 | messageID es.MessageID 12 | } 13 | 14 | func BuildChangeCustomerName( 15 | customerID value.CustomerID, 16 | personName value.PersonName, 17 | ) ChangeCustomerName { 18 | 19 | command := ChangeCustomerName{ 20 | customerID: customerID, 21 | personName: personName, 22 | messageID: es.GenerateMessageID(), 23 | } 24 | 25 | return command 26 | } 27 | 28 | func (command ChangeCustomerName) CustomerID() value.CustomerID { 29 | return command.customerID 30 | } 31 | 32 | func (command ChangeCustomerName) PersonName() value.PersonName { 33 | return command.personName 34 | } 35 | 36 | func (command ChangeCustomerName) MessageID() es.MessageID { 37 | return command.messageID 38 | } 39 | -------------------------------------------------------------------------------- /src/customeraccounts/hexagon/application/domain/ConfirmCustomerEmailAddress.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import ( 4 | "github.com/AntonStoeckl/go-iddd/src/customeraccounts/hexagon/application/domain/customer/value" 5 | "github.com/AntonStoeckl/go-iddd/src/shared/es" 6 | ) 7 | 8 | type ConfirmCustomerEmailAddress struct { 9 | customerID value.CustomerID 10 | confirmationHash value.ConfirmationHash 11 | messageID es.MessageID 12 | } 13 | 14 | func BuildConfirmCustomerEmailAddress( 15 | customerID value.CustomerID, 16 | confirmationHash value.ConfirmationHash, 17 | ) ConfirmCustomerEmailAddress { 18 | 19 | command := ConfirmCustomerEmailAddress{ 20 | customerID: customerID, 21 | confirmationHash: confirmationHash, 22 | messageID: es.GenerateMessageID(), 23 | } 24 | 25 | return command 26 | } 27 | 28 | func (command ConfirmCustomerEmailAddress) CustomerID() value.CustomerID { 29 | return command.customerID 30 | } 31 | 32 | func (command ConfirmCustomerEmailAddress) ConfirmationHash() value.ConfirmationHash { 33 | return command.confirmationHash 34 | } 35 | 36 | func (command ConfirmCustomerEmailAddress) MessageID() es.MessageID { 37 | return command.messageID 38 | } 39 | -------------------------------------------------------------------------------- /src/customeraccounts/hexagon/application/domain/CustomerDeleted.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import ( 4 | "github.com/AntonStoeckl/go-iddd/src/customeraccounts/hexagon/application/domain/customer/value" 5 | "github.com/AntonStoeckl/go-iddd/src/shared/es" 6 | ) 7 | 8 | type CustomerDeleted struct { 9 | customerID value.CustomerID 10 | meta es.EventMeta 11 | } 12 | 13 | func BuildCustomerDeleted( 14 | customerID value.CustomerID, 15 | causationID es.MessageID, 16 | streamVersion uint, 17 | ) CustomerDeleted { 18 | 19 | event := CustomerDeleted{ 20 | customerID: customerID, 21 | } 22 | 23 | event.meta = es.BuildEventMeta(event, causationID, streamVersion) 24 | 25 | return event 26 | } 27 | 28 | func RebuildCustomerDeleted( 29 | customerID string, 30 | meta es.EventMeta, 31 | ) CustomerDeleted { 32 | 33 | event := CustomerDeleted{ 34 | customerID: value.RebuildCustomerID(customerID), 35 | meta: meta, 36 | } 37 | 38 | return event 39 | } 40 | 41 | func (event CustomerDeleted) CustomerID() value.CustomerID { 42 | return event.customerID 43 | } 44 | 45 | func (event CustomerDeleted) Meta() es.EventMeta { 46 | return event.meta 47 | } 48 | 49 | func (event CustomerDeleted) IsFailureEvent() bool { 50 | return false 51 | } 52 | 53 | func (event CustomerDeleted) FailureReason() error { 54 | return nil 55 | } 56 | -------------------------------------------------------------------------------- /src/customeraccounts/hexagon/application/domain/CustomerEmailAddressChanged.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import ( 4 | "github.com/AntonStoeckl/go-iddd/src/customeraccounts/hexagon/application/domain/customer/value" 5 | "github.com/AntonStoeckl/go-iddd/src/shared/es" 6 | ) 7 | 8 | type CustomerEmailAddressChanged struct { 9 | customerID value.CustomerID 10 | emailAddress value.UnconfirmedEmailAddress 11 | meta es.EventMeta 12 | } 13 | 14 | func BuildCustomerEmailAddressChanged( 15 | customerID value.CustomerID, 16 | emailAddress value.UnconfirmedEmailAddress, 17 | causationID es.MessageID, 18 | streamVersion uint, 19 | ) CustomerEmailAddressChanged { 20 | 21 | event := CustomerEmailAddressChanged{ 22 | customerID: customerID, 23 | emailAddress: emailAddress, 24 | } 25 | 26 | event.meta = es.BuildEventMeta(event, causationID, streamVersion) 27 | 28 | return event 29 | } 30 | 31 | func RebuildCustomerEmailAddressChanged( 32 | customerID string, 33 | emailAddress string, 34 | confirmationHash string, 35 | meta es.EventMeta, 36 | ) CustomerEmailAddressChanged { 37 | 38 | event := CustomerEmailAddressChanged{ 39 | customerID: value.RebuildCustomerID(customerID), 40 | emailAddress: value.RebuildUnconfirmedEmailAddress(emailAddress, confirmationHash), 41 | meta: meta, 42 | } 43 | 44 | return event 45 | } 46 | 47 | func (event CustomerEmailAddressChanged) CustomerID() value.CustomerID { 48 | return event.customerID 49 | } 50 | 51 | func (event CustomerEmailAddressChanged) EmailAddress() value.UnconfirmedEmailAddress { 52 | return event.emailAddress 53 | } 54 | 55 | func (event CustomerEmailAddressChanged) Meta() es.EventMeta { 56 | return event.meta 57 | } 58 | 59 | func (event CustomerEmailAddressChanged) IsFailureEvent() bool { 60 | return false 61 | } 62 | 63 | func (event CustomerEmailAddressChanged) FailureReason() error { 64 | return nil 65 | } 66 | -------------------------------------------------------------------------------- /src/customeraccounts/hexagon/application/domain/CustomerEmailAddressConfirmationFailed.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import ( 4 | "github.com/AntonStoeckl/go-iddd/src/customeraccounts/hexagon/application/domain/customer/value" 5 | "github.com/AntonStoeckl/go-iddd/src/shared" 6 | "github.com/AntonStoeckl/go-iddd/src/shared/es" 7 | "github.com/cockroachdb/errors" 8 | ) 9 | 10 | type CustomerEmailAddressConfirmationFailed struct { 11 | customerID value.CustomerID 12 | confirmationHash value.ConfirmationHash 13 | reason error 14 | meta es.EventMeta 15 | } 16 | 17 | func BuildCustomerEmailAddressConfirmationFailed( 18 | customerID value.CustomerID, 19 | confirmationHash value.ConfirmationHash, 20 | reason error, 21 | causationID es.MessageID, 22 | streamVersion uint, 23 | ) CustomerEmailAddressConfirmationFailed { 24 | 25 | event := CustomerEmailAddressConfirmationFailed{ 26 | customerID: customerID, 27 | confirmationHash: confirmationHash, 28 | reason: reason, 29 | } 30 | 31 | event.meta = es.BuildEventMeta(event, causationID, streamVersion) 32 | 33 | return event 34 | } 35 | 36 | func RebuildCustomerEmailAddressConfirmationFailed( 37 | customerID string, 38 | confirmationHash string, 39 | reason string, 40 | meta es.EventMeta, 41 | ) CustomerEmailAddressConfirmationFailed { 42 | 43 | event := CustomerEmailAddressConfirmationFailed{ 44 | customerID: value.RebuildCustomerID(customerID), 45 | confirmationHash: value.RebuildConfirmationHash(confirmationHash), 46 | reason: errors.Mark(errors.New(reason), shared.ErrDomainConstraintsViolation), 47 | meta: meta, 48 | } 49 | 50 | return event 51 | } 52 | 53 | func (event CustomerEmailAddressConfirmationFailed) CustomerID() value.CustomerID { 54 | return event.customerID 55 | } 56 | 57 | func (event CustomerEmailAddressConfirmationFailed) ConfirmationHash() value.ConfirmationHash { 58 | return event.confirmationHash 59 | } 60 | 61 | func (event CustomerEmailAddressConfirmationFailed) Meta() es.EventMeta { 62 | return event.meta 63 | } 64 | 65 | func (event CustomerEmailAddressConfirmationFailed) IsFailureEvent() bool { 66 | return true 67 | } 68 | 69 | func (event CustomerEmailAddressConfirmationFailed) FailureReason() error { 70 | return event.reason 71 | } 72 | -------------------------------------------------------------------------------- /src/customeraccounts/hexagon/application/domain/CustomerEmailAddressConfirmed.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import ( 4 | "github.com/AntonStoeckl/go-iddd/src/customeraccounts/hexagon/application/domain/customer/value" 5 | "github.com/AntonStoeckl/go-iddd/src/shared/es" 6 | ) 7 | 8 | type CustomerEmailAddressConfirmed struct { 9 | customerID value.CustomerID 10 | emailAddress value.ConfirmedEmailAddress 11 | meta es.EventMeta 12 | } 13 | 14 | func BuildCustomerEmailAddressConfirmed( 15 | customerID value.CustomerID, 16 | emailAddress value.ConfirmedEmailAddress, 17 | causationID es.MessageID, 18 | streamVersion uint, 19 | ) CustomerEmailAddressConfirmed { 20 | 21 | event := CustomerEmailAddressConfirmed{ 22 | customerID: customerID, 23 | emailAddress: emailAddress, 24 | } 25 | 26 | event.meta = es.BuildEventMeta(event, causationID, streamVersion) 27 | 28 | return event 29 | } 30 | 31 | func RebuildCustomerEmailAddressConfirmed( 32 | customerID string, 33 | emailAddress string, 34 | meta es.EventMeta, 35 | ) CustomerEmailAddressConfirmed { 36 | 37 | event := CustomerEmailAddressConfirmed{ 38 | customerID: value.RebuildCustomerID(customerID), 39 | emailAddress: value.ConfirmedEmailAddress(emailAddress), 40 | meta: meta, 41 | } 42 | 43 | return event 44 | } 45 | 46 | func (event CustomerEmailAddressConfirmed) CustomerID() value.CustomerID { 47 | return event.customerID 48 | } 49 | 50 | func (event CustomerEmailAddressConfirmed) EmailAddress() value.ConfirmedEmailAddress { 51 | return event.emailAddress 52 | } 53 | 54 | func (event CustomerEmailAddressConfirmed) Meta() es.EventMeta { 55 | return event.meta 56 | } 57 | 58 | func (event CustomerEmailAddressConfirmed) IsFailureEvent() bool { 59 | return false 60 | } 61 | 62 | func (event CustomerEmailAddressConfirmed) FailureReason() error { 63 | return nil 64 | } 65 | -------------------------------------------------------------------------------- /src/customeraccounts/hexagon/application/domain/CustomerNameChanged.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import ( 4 | "github.com/AntonStoeckl/go-iddd/src/customeraccounts/hexagon/application/domain/customer/value" 5 | "github.com/AntonStoeckl/go-iddd/src/shared/es" 6 | ) 7 | 8 | type CustomerNameChanged struct { 9 | customerID value.CustomerID 10 | personName value.PersonName 11 | meta es.EventMeta 12 | } 13 | 14 | func BuildCustomerNameChanged( 15 | customerID value.CustomerID, 16 | personName value.PersonName, 17 | causationID es.MessageID, 18 | streamVersion uint, 19 | ) CustomerNameChanged { 20 | 21 | event := CustomerNameChanged{ 22 | customerID: customerID, 23 | personName: personName, 24 | } 25 | 26 | event.meta = es.BuildEventMeta(event, causationID, streamVersion) 27 | 28 | return event 29 | } 30 | 31 | func RebuildCustomerNameChanged( 32 | customerID string, 33 | givenName string, 34 | familyName string, 35 | meta es.EventMeta, 36 | ) CustomerNameChanged { 37 | 38 | event := CustomerNameChanged{ 39 | customerID: value.RebuildCustomerID(customerID), 40 | personName: value.RebuildPersonName(givenName, familyName), 41 | meta: meta, 42 | } 43 | 44 | return event 45 | } 46 | 47 | func (event CustomerNameChanged) CustomerID() value.CustomerID { 48 | return event.customerID 49 | } 50 | 51 | func (event CustomerNameChanged) PersonName() value.PersonName { 52 | return event.personName 53 | } 54 | 55 | func (event CustomerNameChanged) Meta() es.EventMeta { 56 | return event.meta 57 | } 58 | 59 | func (event CustomerNameChanged) IsFailureEvent() bool { 60 | return false 61 | } 62 | 63 | func (event CustomerNameChanged) FailureReason() error { 64 | return nil 65 | } 66 | -------------------------------------------------------------------------------- /src/customeraccounts/hexagon/application/domain/CustomerRegistered.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import ( 4 | "github.com/AntonStoeckl/go-iddd/src/customeraccounts/hexagon/application/domain/customer/value" 5 | "github.com/AntonStoeckl/go-iddd/src/shared/es" 6 | ) 7 | 8 | type CustomerRegistered struct { 9 | customerID value.CustomerID 10 | emailAddress value.UnconfirmedEmailAddress 11 | personName value.PersonName 12 | meta es.EventMeta 13 | } 14 | 15 | func BuildCustomerRegistered( 16 | customerID value.CustomerID, 17 | emailAddress value.UnconfirmedEmailAddress, 18 | personName value.PersonName, 19 | causationID es.MessageID, 20 | streamVersion uint, 21 | ) CustomerRegistered { 22 | 23 | event := CustomerRegistered{ 24 | customerID: customerID, 25 | emailAddress: emailAddress, 26 | personName: personName, 27 | } 28 | 29 | event.meta = es.BuildEventMeta(event, causationID, streamVersion) 30 | 31 | return event 32 | } 33 | 34 | func RebuildCustomerRegistered( 35 | customerID string, 36 | emailAddress string, 37 | confirmationHash string, 38 | givenName string, 39 | familyName string, 40 | meta es.EventMeta, 41 | ) CustomerRegistered { 42 | 43 | event := CustomerRegistered{ 44 | customerID: value.RebuildCustomerID(customerID), 45 | emailAddress: value.RebuildUnconfirmedEmailAddress(emailAddress, confirmationHash), 46 | personName: value.RebuildPersonName(givenName, familyName), 47 | meta: meta, 48 | } 49 | 50 | return event 51 | } 52 | 53 | func (event CustomerRegistered) CustomerID() value.CustomerID { 54 | return event.customerID 55 | } 56 | 57 | func (event CustomerRegistered) EmailAddress() value.UnconfirmedEmailAddress { 58 | return event.emailAddress 59 | } 60 | 61 | func (event CustomerRegistered) PersonName() value.PersonName { 62 | return event.personName 63 | } 64 | 65 | func (event CustomerRegistered) Meta() es.EventMeta { 66 | return event.meta 67 | } 68 | 69 | func (event CustomerRegistered) IsFailureEvent() bool { 70 | return false 71 | } 72 | 73 | func (event CustomerRegistered) FailureReason() error { 74 | return nil 75 | } 76 | -------------------------------------------------------------------------------- /src/customeraccounts/hexagon/application/domain/DeleteCustomer.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import ( 4 | "github.com/AntonStoeckl/go-iddd/src/customeraccounts/hexagon/application/domain/customer/value" 5 | "github.com/AntonStoeckl/go-iddd/src/shared/es" 6 | ) 7 | 8 | type DeleteCustomer struct { 9 | customerID value.CustomerID 10 | messageID es.MessageID 11 | } 12 | 13 | func BuildDeleteCustomer(customerID value.CustomerID) DeleteCustomer { 14 | command := DeleteCustomer{ 15 | customerID: customerID, 16 | messageID: es.GenerateMessageID(), 17 | } 18 | 19 | return command 20 | } 21 | 22 | func (command DeleteCustomer) CustomerID() value.CustomerID { 23 | return command.customerID 24 | } 25 | 26 | func (command DeleteCustomer) MessageID() es.MessageID { 27 | return command.messageID 28 | } 29 | -------------------------------------------------------------------------------- /src/customeraccounts/hexagon/application/domain/RegisterCustomer.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import ( 4 | "github.com/AntonStoeckl/go-iddd/src/customeraccounts/hexagon/application/domain/customer/value" 5 | "github.com/AntonStoeckl/go-iddd/src/shared/es" 6 | ) 7 | 8 | type RegisterCustomer struct { 9 | customerID value.CustomerID 10 | emailAddress value.UnconfirmedEmailAddress 11 | personName value.PersonName 12 | messageID es.MessageID 13 | } 14 | 15 | func BuildRegisterCustomer( 16 | customerID value.CustomerID, 17 | emailAddress value.UnconfirmedEmailAddress, 18 | personName value.PersonName, 19 | ) RegisterCustomer { 20 | 21 | command := RegisterCustomer{ 22 | customerID: customerID, 23 | emailAddress: emailAddress, 24 | personName: personName, 25 | messageID: es.GenerateMessageID(), 26 | } 27 | 28 | return command 29 | } 30 | 31 | func (command RegisterCustomer) CustomerID() value.CustomerID { 32 | return command.customerID 33 | } 34 | 35 | func (command RegisterCustomer) EmailAddress() value.UnconfirmedEmailAddress { 36 | return command.emailAddress 37 | } 38 | 39 | func (command RegisterCustomer) PersonName() value.PersonName { 40 | return command.personName 41 | } 42 | 43 | func (command RegisterCustomer) MessageID() es.MessageID { 44 | return command.messageID 45 | } 46 | -------------------------------------------------------------------------------- /src/customeraccounts/hexagon/application/domain/customer/AssertUniqueEmailAddresses.go: -------------------------------------------------------------------------------- 1 | package customer 2 | 3 | import ( 4 | "github.com/AntonStoeckl/go-iddd/src/customeraccounts/hexagon/application/domain" 5 | "github.com/AntonStoeckl/go-iddd/src/customeraccounts/hexagon/application/domain/customer/value" 6 | "github.com/AntonStoeckl/go-iddd/src/shared/es" 7 | ) 8 | 9 | const ( 10 | ShouldAddUniqueEmailAddress = iota 11 | ShouldReplaceUniqueEmailAddress 12 | ShouldRemoveUniqueEmailAddress 13 | ) 14 | 15 | type ForBuildingUniqueEmailAddressAssertions func(recordedEvents ...es.DomainEvent) UniqueEmailAddressAssertions 16 | 17 | type UniqueEmailAddressAssertion struct { 18 | desiredAction int 19 | customerID value.CustomerID 20 | emailAddressToAdd value.UnconfirmedEmailAddress 21 | } 22 | 23 | type UniqueEmailAddressAssertions []UniqueEmailAddressAssertion 24 | 25 | func (spec UniqueEmailAddressAssertion) DesiredAction() int { 26 | return spec.desiredAction 27 | } 28 | 29 | func (spec UniqueEmailAddressAssertion) CustomerID() value.CustomerID { 30 | return spec.customerID 31 | } 32 | 33 | func (spec UniqueEmailAddressAssertion) EmailAddressToAdd() value.UnconfirmedEmailAddress { 34 | return spec.emailAddressToAdd 35 | } 36 | 37 | func BuildUniqueEmailAddressAssertions(recordedEvents ...es.DomainEvent) UniqueEmailAddressAssertions { 38 | var specifications UniqueEmailAddressAssertions 39 | 40 | for _, event := range recordedEvents { 41 | switch actualEvent := event.(type) { 42 | case domain.CustomerRegistered: 43 | specifications = append( 44 | specifications, 45 | UniqueEmailAddressAssertion{ 46 | desiredAction: ShouldAddUniqueEmailAddress, 47 | customerID: actualEvent.CustomerID(), 48 | emailAddressToAdd: actualEvent.EmailAddress(), 49 | }, 50 | ) 51 | case domain.CustomerEmailAddressChanged: 52 | specifications = append( 53 | specifications, 54 | UniqueEmailAddressAssertion{ 55 | desiredAction: ShouldReplaceUniqueEmailAddress, 56 | customerID: actualEvent.CustomerID(), 57 | emailAddressToAdd: actualEvent.EmailAddress(), 58 | }, 59 | ) 60 | case domain.CustomerDeleted: 61 | specifications = append( 62 | specifications, 63 | UniqueEmailAddressAssertion{ 64 | desiredAction: ShouldRemoveUniqueEmailAddress, 65 | customerID: actualEvent.CustomerID(), 66 | }, 67 | ) 68 | } 69 | } 70 | 71 | return specifications 72 | } 73 | -------------------------------------------------------------------------------- /src/customeraccounts/hexagon/application/domain/customer/ChangeEmailAddress.go: -------------------------------------------------------------------------------- 1 | package customer 2 | 3 | import ( 4 | "github.com/AntonStoeckl/go-iddd/src/customeraccounts/hexagon/application/domain" 5 | "github.com/AntonStoeckl/go-iddd/src/shared/es" 6 | "github.com/cockroachdb/errors" 7 | ) 8 | 9 | func ChangeEmailAddress(eventStream es.EventStream, command domain.ChangeCustomerEmailAddress) (es.RecordedEvents, error) { 10 | customer := buildCurrentStateFrom(eventStream) 11 | 12 | if err := assertNotDeleted(customer); err != nil { 13 | return nil, errors.Wrap(err, "changeEmailAddress") 14 | } 15 | 16 | if customer.emailAddress.Equals(command.EmailAddress()) { 17 | return nil, nil 18 | } 19 | 20 | event := domain.BuildCustomerEmailAddressChanged( 21 | command.CustomerID(), 22 | command.EmailAddress(), 23 | command.MessageID(), 24 | customer.currentStreamVersion+1, 25 | ) 26 | 27 | return es.RecordedEvents{event}, nil 28 | } 29 | -------------------------------------------------------------------------------- /src/customeraccounts/hexagon/application/domain/customer/ChangeEmailAddress_test.go: -------------------------------------------------------------------------------- 1 | package customer_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/AntonStoeckl/go-iddd/src/customeraccounts/hexagon/application/domain" 7 | "github.com/AntonStoeckl/go-iddd/src/customeraccounts/hexagon/application/domain/customer" 8 | "github.com/AntonStoeckl/go-iddd/src/customeraccounts/hexagon/application/domain/customer/value" 9 | "github.com/AntonStoeckl/go-iddd/src/shared" 10 | "github.com/AntonStoeckl/go-iddd/src/shared/es" 11 | "github.com/cockroachdb/errors" 12 | . "github.com/smartystreets/goconvey/convey" 13 | ) 14 | 15 | func TestChangeEmailAddress(t *testing.T) { 16 | Convey("Prepare test artifacts", t, func() { 17 | var err error 18 | var recordedEvents es.RecordedEvents 19 | 20 | customerID := value.GenerateCustomerID() 21 | emailAddress, err := value.BuildUnconfirmedEmailAddress("kevin@ball.com") 22 | So(err, ShouldBeNil) 23 | personName, err := value.BuildPersonName("Kevin", "Ball") 24 | So(err, ShouldBeNil) 25 | changedEmailAddress, err := value.BuildUnconfirmedEmailAddress("latoya@ball.net") 26 | So(err, ShouldBeNil) 27 | 28 | command := domain.BuildChangeCustomerEmailAddress( 29 | customerID, 30 | changedEmailAddress, 31 | ) 32 | 33 | commandWithOriginalEmailAddress := domain.BuildChangeCustomerEmailAddress( 34 | customerID, 35 | emailAddress, 36 | ) 37 | 38 | customerRegistered := domain.BuildCustomerRegistered( 39 | customerID, 40 | emailAddress, 41 | personName, 42 | es.GenerateMessageID(), 43 | 1, 44 | ) 45 | 46 | customerEmailAddressChanged := domain.BuildCustomerEmailAddressChanged( 47 | customerID, 48 | changedEmailAddress, 49 | es.GenerateMessageID(), 50 | 2, 51 | ) 52 | 53 | customerDeleted := domain.BuildCustomerDeleted( 54 | customerID, 55 | es.GenerateMessageID(), 56 | 2, 57 | ) 58 | 59 | Convey("\nSCENARIO 1: Change a Customer's emailAddress", func() { 60 | Convey("Given CustomerRegistered", func() { 61 | eventStream := es.EventStream{customerRegistered} 62 | 63 | Convey("When ChangeCustomerEmailAddress", func() { 64 | recordedEvents, err = customer.ChangeEmailAddress(eventStream, command) 65 | So(err, ShouldBeNil) 66 | 67 | Convey("Then CustomerEmailAddressChanged", func() { 68 | So(recordedEvents, ShouldHaveLength, 1) 69 | event, ok := recordedEvents[0].(domain.CustomerEmailAddressChanged) 70 | So(ok, ShouldBeTrue) 71 | So(event, ShouldNotBeNil) 72 | So(event.CustomerID().Equals(customerID), ShouldBeTrue) 73 | So(event.EmailAddress().Equals(changedEmailAddress), ShouldBeTrue) 74 | So(event.EmailAddress().ConfirmationHash().Equals(changedEmailAddress.ConfirmationHash()), ShouldBeTrue) 75 | So(event.IsFailureEvent(), ShouldBeFalse) 76 | So(event.FailureReason(), ShouldBeNil) 77 | So(event.Meta().CausationID(), ShouldEqual, command.MessageID().String()) 78 | So(event.Meta().MessageID(), ShouldNotBeEmpty) 79 | So(event.Meta().StreamVersion(), ShouldEqual, 2) 80 | }) 81 | }) 82 | }) 83 | }) 84 | 85 | Convey("\nSCENARIO 2: Try to change a Customer's emailAddress to the value he registered with", func() { 86 | Convey("Given CustomerRegistered", func() { 87 | eventStream := es.EventStream{customerRegistered} 88 | 89 | Convey("When ChangeCustomerEmailAddress", func() { 90 | recordedEvents, err = customer.ChangeEmailAddress(eventStream, commandWithOriginalEmailAddress) 91 | So(err, ShouldBeNil) 92 | 93 | Convey("Then no event", func() { 94 | So(recordedEvents, ShouldBeEmpty) 95 | }) 96 | }) 97 | }) 98 | }) 99 | 100 | Convey("\nSCENARIO 3: Try to change a Customer's emailAddress to the value it was already changed to", func() { 101 | Convey("Given CustomerRegistered", func() { 102 | eventStream := es.EventStream{customerRegistered} 103 | 104 | Convey("and CustomerEmailAddressChanged", func() { 105 | eventStream = append(eventStream, customerEmailAddressChanged) 106 | 107 | Convey("When ChangeCustomerEmailAddress", func() { 108 | recordedEvents, err = customer.ChangeEmailAddress(eventStream, command) 109 | So(err, ShouldBeNil) 110 | 111 | Convey("Then no event", func() { 112 | So(recordedEvents, ShouldBeEmpty) 113 | }) 114 | }) 115 | }) 116 | }) 117 | }) 118 | 119 | Convey("\nSCENARIO 4: Try to change a Customer's emailAddress when the account was deleted", func() { 120 | Convey("Given CustomerRegistered", func() { 121 | eventStream := es.EventStream{customerRegistered} 122 | 123 | Convey("Given CustomerDeleted", func() { 124 | eventStream = append(eventStream, customerDeleted) 125 | 126 | Convey("When ChangeCustomerEmailAddress", func() { 127 | _, err := customer.ChangeEmailAddress(eventStream, command) 128 | 129 | Convey("Then it should report an error", func() { 130 | So(err, ShouldBeError) 131 | So(errors.Is(err, shared.ErrNotFound), ShouldBeTrue) 132 | }) 133 | }) 134 | }) 135 | }) 136 | }) 137 | }) 138 | } 139 | -------------------------------------------------------------------------------- /src/customeraccounts/hexagon/application/domain/customer/ChangeName.go: -------------------------------------------------------------------------------- 1 | package customer 2 | 3 | import ( 4 | "github.com/AntonStoeckl/go-iddd/src/customeraccounts/hexagon/application/domain" 5 | "github.com/AntonStoeckl/go-iddd/src/shared/es" 6 | "github.com/cockroachdb/errors" 7 | ) 8 | 9 | func ChangeName(eventStream es.EventStream, command domain.ChangeCustomerName) (es.RecordedEvents, error) { 10 | customer := buildCurrentStateFrom(eventStream) 11 | 12 | if err := assertNotDeleted(customer); err != nil { 13 | return nil, errors.Wrap(err, "changeCustomerName") 14 | } 15 | 16 | if customer.personName.Equals(command.PersonName()) { 17 | return nil, nil 18 | } 19 | 20 | event := domain.BuildCustomerNameChanged( 21 | command.CustomerID(), 22 | command.PersonName(), 23 | command.MessageID(), 24 | customer.currentStreamVersion+1, 25 | ) 26 | 27 | return es.RecordedEvents{event}, nil 28 | } 29 | -------------------------------------------------------------------------------- /src/customeraccounts/hexagon/application/domain/customer/ChangeName_test.go: -------------------------------------------------------------------------------- 1 | package customer_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/AntonStoeckl/go-iddd/src/customeraccounts/hexagon/application/domain" 7 | "github.com/AntonStoeckl/go-iddd/src/customeraccounts/hexagon/application/domain/customer" 8 | "github.com/AntonStoeckl/go-iddd/src/customeraccounts/hexagon/application/domain/customer/value" 9 | "github.com/AntonStoeckl/go-iddd/src/shared" 10 | "github.com/AntonStoeckl/go-iddd/src/shared/es" 11 | "github.com/cockroachdb/errors" 12 | . "github.com/smartystreets/goconvey/convey" 13 | ) 14 | 15 | func TestChangeName(t *testing.T) { 16 | Convey("Prepare test artifacts", t, func() { 17 | var err error 18 | var recordedEvents es.RecordedEvents 19 | 20 | customerID := value.GenerateCustomerID() 21 | emailAddress, err := value.BuildUnconfirmedEmailAddress("kevin@ball.com") 22 | So(err, ShouldBeNil) 23 | personName, err := value.BuildPersonName("Kevin", "Ball") 24 | So(err, ShouldBeNil) 25 | changedPersonName, err := value.BuildPersonName("Latoya", "Ball") 26 | So(err, ShouldBeNil) 27 | 28 | command := domain.BuildChangeCustomerName(customerID, changedPersonName) 29 | commandWithOriginalName := domain.BuildChangeCustomerName(customerID, personName) 30 | 31 | customerRegistered := domain.BuildCustomerRegistered( 32 | customerID, 33 | emailAddress, 34 | personName, 35 | es.GenerateMessageID(), 36 | 1, 37 | ) 38 | 39 | customerDeleted := domain.BuildCustomerDeleted( 40 | customerID, 41 | es.GenerateMessageID(), 42 | 2, 43 | ) 44 | 45 | Convey("\nSCENARIO 1: Change a Customer's name", func() { 46 | Convey("Given CustomerRegistered", func() { 47 | eventStream := es.EventStream{customerRegistered} 48 | 49 | Convey("When ChangeCustomerName", func() { 50 | recordedEvents, err = customer.ChangeName(eventStream, command) 51 | So(err, ShouldBeNil) 52 | 53 | Convey("Then CustomerNameChanged", func() { 54 | So(recordedEvents, ShouldHaveLength, 1) 55 | event, ok := recordedEvents[0].(domain.CustomerNameChanged) 56 | So(ok, ShouldBeTrue) 57 | So(event, ShouldNotBeNil) 58 | So(event.CustomerID().Equals(customerID), ShouldBeTrue) 59 | So(event.PersonName().Equals(changedPersonName), ShouldBeTrue) 60 | So(event.IsFailureEvent(), ShouldBeFalse) 61 | So(event.FailureReason(), ShouldBeNil) 62 | So(event.Meta().CausationID(), ShouldEqual, command.MessageID().String()) 63 | So(event.Meta().MessageID(), ShouldNotBeEmpty) 64 | So(event.Meta().StreamVersion(), ShouldEqual, 2) 65 | }) 66 | }) 67 | }) 68 | }) 69 | 70 | Convey("\nSCENARIO 2: Try to change a Customer's name to the value he registered with", func() { 71 | Convey("Given CustomerRegistered", func() { 72 | eventStream := es.EventStream{customerRegistered} 73 | 74 | Convey("When ChangeCustomerName", func() { 75 | recordedEvents, err = customer.ChangeName(eventStream, commandWithOriginalName) 76 | So(err, ShouldBeNil) 77 | 78 | Convey("Then no event", func() { 79 | So(recordedEvents, ShouldBeEmpty) 80 | }) 81 | }) 82 | }) 83 | }) 84 | 85 | Convey("\nSCENARIO 3: Try to change a Customer's name to the value it was already changed to", func() { 86 | Convey("Given CustomerRegistered", func() { 87 | eventStream := es.EventStream{customerRegistered} 88 | 89 | Convey("and CustomerNameChanged", func() { 90 | nameChanged := domain.BuildCustomerNameChanged( 91 | customerID, 92 | changedPersonName, 93 | es.GenerateMessageID(), 94 | 2, 95 | ) 96 | 97 | eventStream = append(eventStream, nameChanged) 98 | 99 | Convey("When ChangeCustomerName", func() { 100 | recordedEvents, err = customer.ChangeName(eventStream, command) 101 | So(err, ShouldBeNil) 102 | 103 | Convey("Then no event", func() { 104 | So(recordedEvents, ShouldBeEmpty) 105 | }) 106 | }) 107 | }) 108 | }) 109 | }) 110 | 111 | Convey("\nSCENARIO 4: Try to change a Customer's name when the account was deleted", func() { 112 | Convey("Given CustomerRegistered", func() { 113 | eventStream := es.EventStream{customerRegistered} 114 | 115 | Convey("Given CustomerDeleted", func() { 116 | eventStream = append( 117 | eventStream, 118 | customerDeleted, 119 | ) 120 | 121 | Convey("When ChangeCustomerName", func() { 122 | _, err := customer.ChangeName(eventStream, command) 123 | 124 | Convey("Then it should report an error", func() { 125 | So(err, ShouldBeError) 126 | So(errors.Is(err, shared.ErrNotFound), ShouldBeTrue) 127 | }) 128 | }) 129 | }) 130 | }) 131 | }) 132 | }) 133 | } 134 | -------------------------------------------------------------------------------- /src/customeraccounts/hexagon/application/domain/customer/ConfirmEmailAddress.go: -------------------------------------------------------------------------------- 1 | package customer 2 | 3 | import ( 4 | "github.com/AntonStoeckl/go-iddd/src/customeraccounts/hexagon/application/domain" 5 | "github.com/AntonStoeckl/go-iddd/src/customeraccounts/hexagon/application/domain/customer/value" 6 | "github.com/AntonStoeckl/go-iddd/src/shared/es" 7 | "github.com/cockroachdb/errors" 8 | ) 9 | 10 | func ConfirmEmailAddress(eventStream es.EventStream, command domain.ConfirmCustomerEmailAddress) (es.RecordedEvents, error) { 11 | customer := buildCurrentStateFrom(eventStream) 12 | 13 | if err := assertNotDeleted(customer); err != nil { 14 | return nil, errors.Wrap(err, "confirmEmailAddress") 15 | } 16 | 17 | switch actualEmailAddress := customer.emailAddress.(type) { 18 | case value.ConfirmedEmailAddress: 19 | return nil, nil 20 | case value.UnconfirmedEmailAddress: 21 | confirmedEmailAddress, err := value.ConfirmEmailAddressWithHash(actualEmailAddress, command.ConfirmationHash()) 22 | 23 | if err != nil { 24 | return es.RecordedEvents{ 25 | domain.BuildCustomerEmailAddressConfirmationFailed( 26 | command.CustomerID(), 27 | command.ConfirmationHash(), 28 | err, 29 | command.MessageID(), 30 | customer.currentStreamVersion+1, 31 | ), 32 | }, nil 33 | } 34 | 35 | return es.RecordedEvents{ 36 | domain.BuildCustomerEmailAddressConfirmed( 37 | command.CustomerID(), 38 | confirmedEmailAddress, 39 | command.MessageID(), 40 | customer.currentStreamVersion+1, 41 | ), 42 | }, nil 43 | default: 44 | // until Go has "union types" we need to use an interface and this case could exist - we don't want to hide it 45 | panic("ConfirmEmailAddress(): emailAddress is neither UnconfirmedEmailAddress nor ConfirmedEmailAddress") 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/customeraccounts/hexagon/application/domain/customer/ConfirmEmailAddress_test.go: -------------------------------------------------------------------------------- 1 | package customer_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/AntonStoeckl/go-iddd/src/customeraccounts/hexagon/application/domain" 7 | "github.com/AntonStoeckl/go-iddd/src/customeraccounts/hexagon/application/domain/customer" 8 | "github.com/AntonStoeckl/go-iddd/src/customeraccounts/hexagon/application/domain/customer/value" 9 | "github.com/AntonStoeckl/go-iddd/src/shared" 10 | "github.com/AntonStoeckl/go-iddd/src/shared/es" 11 | "github.com/cockroachdb/errors" 12 | . "github.com/smartystreets/goconvey/convey" 13 | ) 14 | 15 | func TestConfirmEmailAddress(t *testing.T) { 16 | Convey("Prepare test artifacts", t, func() { 17 | var err error 18 | var recordedEvents es.RecordedEvents 19 | 20 | customerID := value.GenerateCustomerID() 21 | emailAddress, err := value.BuildUnconfirmedEmailAddress("kevin@ball.com") 22 | So(err, ShouldBeNil) 23 | invalidConfirmationHash := value.RebuildConfirmationHash("invalid_hash") 24 | personName, err := value.BuildPersonName("Kevin", "Ball") 25 | So(err, ShouldBeNil) 26 | 27 | command := domain.BuildConfirmCustomerEmailAddress(customerID, emailAddress.ConfirmationHash()) 28 | commandWithInvalidHash := domain.BuildConfirmCustomerEmailAddress(customerID, invalidConfirmationHash) 29 | 30 | customerRegistered := domain.BuildCustomerRegistered( 31 | customerID, 32 | emailAddress, 33 | personName, 34 | es.GenerateMessageID(), 35 | 1, 36 | ) 37 | 38 | confirmedEmailAddress, err := value.ConfirmEmailAddressWithHash(emailAddress, emailAddress.ConfirmationHash()) 39 | So(err, ShouldBeNil) 40 | 41 | customerEmailAddressConfirmed := domain.BuildCustomerEmailAddressConfirmed( 42 | customerID, 43 | confirmedEmailAddress, 44 | es.GenerateMessageID(), 45 | 2, 46 | ) 47 | 48 | customerDeleted := domain.BuildCustomerDeleted( 49 | customerID, 50 | es.GenerateMessageID(), 51 | 2, 52 | ) 53 | 54 | Convey("\nSCENARIO 1: ConfirmEmailAddress a Customer's emailAddress with the right confirmationHash", func() { 55 | Convey("Given CustomerRegistered", func() { 56 | eventStream := es.EventStream{customerRegistered} 57 | 58 | Convey("When ConfirmCustomerEmailAddress", func() { 59 | recordedEvents, err = customer.ConfirmEmailAddress(eventStream, command) 60 | So(err, ShouldBeNil) 61 | 62 | Convey("Then CustomerEmailAddressConfirmed", func() { 63 | So(recordedEvents, ShouldHaveLength, 1) 64 | event, ok := recordedEvents[0].(domain.CustomerEmailAddressConfirmed) 65 | So(ok, ShouldBeTrue) 66 | So(event.CustomerID().Equals(customerID), ShouldBeTrue) 67 | So(event.EmailAddress().Equals(emailAddress), ShouldBeTrue) 68 | So(event.Meta().CausationID(), ShouldEqual, command.MessageID().String()) 69 | So(event.Meta().MessageID(), ShouldNotBeEmpty) 70 | So(event.Meta().StreamVersion(), ShouldEqual, 2) 71 | }) 72 | }) 73 | }) 74 | }) 75 | 76 | Convey("\nSCENARIO 2: ConfirmEmailAddress a Customer's emailAddress with a wrong confirmationHash", func() { 77 | Convey("Given CustomerRegistered", func() { 78 | eventStream := es.EventStream{customerRegistered} 79 | 80 | Convey("When ConfirmCustomerEmailAddress", func() { 81 | recordedEvents, err = customer.ConfirmEmailAddress(eventStream, commandWithInvalidHash) 82 | So(err, ShouldBeNil) 83 | 84 | Convey("Then CustomerEmailAddressConfirmationFailed", func() { 85 | So(recordedEvents, ShouldHaveLength, 1) 86 | event, ok := recordedEvents[0].(domain.CustomerEmailAddressConfirmationFailed) 87 | So(ok, ShouldBeTrue) 88 | So(event.CustomerID().Equals(customerID), ShouldBeTrue) 89 | So(event.ConfirmationHash().Equals(invalidConfirmationHash), ShouldBeTrue) 90 | So(event.IsFailureEvent(), ShouldBeTrue) 91 | So(event.FailureReason(), ShouldBeError) 92 | So(event.Meta().CausationID(), ShouldEqual, commandWithInvalidHash.MessageID().String()) 93 | So(event.Meta().MessageID(), ShouldNotBeEmpty) 94 | So(event.Meta().StreamVersion(), ShouldEqual, 2) 95 | }) 96 | }) 97 | }) 98 | }) 99 | 100 | Convey("\nSCENARIO 3: Try to confirm a Customer's emailAddress again with the right confirmationHash", func() { 101 | Convey("Given CustomerRegistered", func() { 102 | eventStream := es.EventStream{customerRegistered} 103 | 104 | Convey("and CustomerEmailAddressConfirmed", func() { 105 | eventStream = append(eventStream, customerEmailAddressConfirmed) 106 | 107 | Convey("When ConfirmCustomerEmailAddress", func() { 108 | recordedEvents, err = customer.ConfirmEmailAddress(eventStream, command) 109 | So(err, ShouldBeNil) 110 | 111 | Convey("Then no event", func() { 112 | So(recordedEvents, ShouldBeEmpty) 113 | }) 114 | }) 115 | }) 116 | }) 117 | }) 118 | 119 | Convey("\nSCENARIO 4: Try to confirm a Customer's emailAddress again with a wrong confirmationHash", func() { 120 | Convey("Given CustomerRegistered", func() { 121 | eventStream := es.EventStream{customerRegistered} 122 | 123 | Convey("and CustomerEmailAddressConfirmed", func() { 124 | eventStream = append(eventStream, customerEmailAddressConfirmed) 125 | 126 | Convey("When ConfirmCustomerEmailAddress", func() { 127 | recordedEvents, err = customer.ConfirmEmailAddress(eventStream, commandWithInvalidHash) 128 | So(err, ShouldBeNil) 129 | 130 | Convey("Then no event", func() { 131 | So(recordedEvents, ShouldBeEmpty) 132 | }) 133 | }) 134 | }) 135 | }) 136 | }) 137 | 138 | Convey("\nSCENARIO 6: Try to confirm a Customer's emailAddress when the account was deleted", func() { 139 | Convey("Given CustomerRegistered", func() { 140 | eventStream := es.EventStream{customerRegistered} 141 | 142 | Convey("Given CustomerDeleted", func() { 143 | eventStream = append(eventStream, customerDeleted) 144 | 145 | Convey("When ConfirmCustomerEmailAddress", func() { 146 | _, err := customer.ConfirmEmailAddress(eventStream, command) 147 | 148 | Convey("Then it should report an error", func() { 149 | So(err, ShouldBeError) 150 | So(errors.Is(err, shared.ErrNotFound), ShouldBeTrue) 151 | }) 152 | }) 153 | }) 154 | }) 155 | }) 156 | }) 157 | } 158 | 159 | func TestConfirmEmailAddressAfterItWasChanged(t *testing.T) { 160 | Convey("Prepare test artifacts", t, func() { 161 | var err error 162 | var recordedEvents es.RecordedEvents 163 | 164 | customerID := value.GenerateCustomerID() 165 | emailAddress, err := value.BuildUnconfirmedEmailAddress("kevin@ball.com") 166 | So(err, ShouldBeNil) 167 | changedEmailAddress, err := value.BuildUnconfirmedEmailAddress("latoya@ball.net") 168 | So(err, ShouldBeNil) 169 | personName, err := value.BuildPersonName("Kevin", "Ball") 170 | So(err, ShouldBeNil) 171 | 172 | command := domain.BuildConfirmCustomerEmailAddress(customerID, changedEmailAddress.ConfirmationHash()) 173 | 174 | customerRegistered := domain.BuildCustomerRegistered( 175 | customerID, 176 | emailAddress, 177 | personName, 178 | es.GenerateMessageID(), 179 | 1, 180 | ) 181 | 182 | confirmedEmailAddress, err := value.ConfirmEmailAddressWithHash(emailAddress, emailAddress.ConfirmationHash()) 183 | So(err, ShouldBeNil) 184 | 185 | customerEmailAddressConfirmed := domain.BuildCustomerEmailAddressConfirmed( 186 | customerID, 187 | confirmedEmailAddress, 188 | es.GenerateMessageID(), 189 | 2, 190 | ) 191 | 192 | customerEmailAddressChanged := domain.BuildCustomerEmailAddressChanged( 193 | customerID, 194 | changedEmailAddress, 195 | es.GenerateMessageID(), 196 | 3, 197 | ) 198 | 199 | Convey("\nSCENARIO 1: ConfirmEmailAddress a Customer's changed emailAddress, after the original emailAddress was confirmed", func() { 200 | Convey("Given CustomerRegistered", func() { 201 | eventStream := es.EventStream{customerRegistered} 202 | 203 | Convey("and CustomerEmailAddressConfirmed", func() { 204 | eventStream = append(eventStream, customerEmailAddressConfirmed) 205 | 206 | Convey("and CustomerEmailAddressChanged", func() { 207 | eventStream = append(eventStream, customerEmailAddressChanged) 208 | 209 | Convey("When ConfirmCustomerEmailAddress", func() { 210 | recordedEvents, err = customer.ConfirmEmailAddress(eventStream, command) 211 | So(err, ShouldBeNil) 212 | 213 | Convey("Then CustomerEmailAddressConfirmed", func() { 214 | So(recordedEvents, ShouldHaveLength, 1) 215 | event, ok := recordedEvents[0].(domain.CustomerEmailAddressConfirmed) 216 | So(ok, ShouldBeTrue) 217 | So(event.CustomerID().Equals(customerID), ShouldBeTrue) 218 | So(event.EmailAddress().Equals(changedEmailAddress), ShouldBeTrue) 219 | So(event.IsFailureEvent(), ShouldBeFalse) 220 | So(event.FailureReason(), ShouldBeNil) 221 | So(event.Meta().CausationID(), ShouldEqual, command.MessageID().String()) 222 | So(event.Meta().MessageID(), ShouldNotBeEmpty) 223 | So(event.Meta().StreamVersion(), ShouldEqual, 4) 224 | }) 225 | }) 226 | }) 227 | }) 228 | }) 229 | }) 230 | }) 231 | } 232 | -------------------------------------------------------------------------------- /src/customeraccounts/hexagon/application/domain/customer/Delete.go: -------------------------------------------------------------------------------- 1 | package customer 2 | 3 | import ( 4 | "github.com/AntonStoeckl/go-iddd/src/customeraccounts/hexagon/application/domain" 5 | "github.com/AntonStoeckl/go-iddd/src/shared/es" 6 | ) 7 | 8 | func Delete(eventStream es.EventStream, command domain.DeleteCustomer) es.RecordedEvents { 9 | customer := buildCurrentStateFrom(eventStream) 10 | 11 | if err := assertNotDeleted(customer); err != nil { 12 | return nil 13 | } 14 | 15 | event := domain.BuildCustomerDeleted( 16 | command.CustomerID(), 17 | command.MessageID(), 18 | customer.currentStreamVersion+1, 19 | ) 20 | 21 | return es.RecordedEvents{event} 22 | } 23 | -------------------------------------------------------------------------------- /src/customeraccounts/hexagon/application/domain/customer/Delete_test.go: -------------------------------------------------------------------------------- 1 | package customer_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/AntonStoeckl/go-iddd/src/customeraccounts/hexagon/application/domain" 7 | "github.com/AntonStoeckl/go-iddd/src/customeraccounts/hexagon/application/domain/customer" 8 | "github.com/AntonStoeckl/go-iddd/src/customeraccounts/hexagon/application/domain/customer/value" 9 | "github.com/AntonStoeckl/go-iddd/src/shared/es" 10 | . "github.com/smartystreets/goconvey/convey" 11 | ) 12 | 13 | func TestDelete(t *testing.T) { 14 | Convey("Prepare test artifacts", t, func() { 15 | var err error 16 | var recordedEvents es.RecordedEvents 17 | 18 | customerID := value.GenerateCustomerID() 19 | emailAddress, err := value.BuildUnconfirmedEmailAddress("kevin@ball.com") 20 | So(err, ShouldBeNil) 21 | personName, err := value.BuildPersonName("Kevin", "Ball") 22 | So(err, ShouldBeNil) 23 | 24 | command := domain.BuildDeleteCustomer(customerID) 25 | 26 | customerRegistered := domain.BuildCustomerRegistered( 27 | customerID, 28 | emailAddress, 29 | personName, 30 | es.GenerateMessageID(), 31 | 1, 32 | ) 33 | 34 | customerDeleted := domain.BuildCustomerDeleted( 35 | customerID, 36 | es.GenerateMessageID(), 37 | 2, 38 | ) 39 | 40 | Convey("\nSCENARIO 1: Delete a Customer's account", func() { 41 | Convey("Given CustomerRegistered", func() { 42 | eventStream := es.EventStream{customerRegistered} 43 | 44 | Convey("When DeleteCustomer", func() { 45 | recordedEvents = customer.Delete(eventStream, command) 46 | 47 | Convey("Then CustomerDeleted", func() { 48 | So(recordedEvents, ShouldHaveLength, 1) 49 | event, ok := recordedEvents[0].(domain.CustomerDeleted) 50 | So(ok, ShouldBeTrue) 51 | So(event, ShouldNotBeNil) 52 | So(event.CustomerID().Equals(customerID), ShouldBeTrue) 53 | So(event.IsFailureEvent(), ShouldBeFalse) 54 | So(event.FailureReason(), ShouldBeNil) 55 | So(event.Meta().CausationID(), ShouldEqual, command.MessageID().String()) 56 | So(event.Meta().MessageID(), ShouldNotBeEmpty) 57 | So(event.Meta().StreamVersion(), ShouldEqual, uint(2)) 58 | }) 59 | }) 60 | }) 61 | }) 62 | 63 | Convey("\nSCENARIO 2: Try to delete a Customer's account again", func() { 64 | Convey("Given CustomerRegistered", func() { 65 | eventStream := es.EventStream{customerRegistered} 66 | 67 | Convey("and CustomerDeleted", func() { 68 | eventStream = append(eventStream, customerDeleted) 69 | 70 | Convey("When DeleteCustomer", func() { 71 | recordedEvents = customer.Delete(eventStream, command) 72 | 73 | Convey("Then no Event", func() { 74 | So(recordedEvents, ShouldBeEmpty) 75 | }) 76 | }) 77 | }) 78 | }) 79 | }) 80 | }) 81 | } 82 | -------------------------------------------------------------------------------- /src/customeraccounts/hexagon/application/domain/customer/Register.go: -------------------------------------------------------------------------------- 1 | package customer 2 | 3 | import ( 4 | "github.com/AntonStoeckl/go-iddd/src/customeraccounts/hexagon/application/domain" 5 | ) 6 | 7 | func Register(command domain.RegisterCustomer) domain.CustomerRegistered { 8 | event := domain.BuildCustomerRegistered( 9 | command.CustomerID(), 10 | command.EmailAddress(), 11 | command.PersonName(), 12 | command.MessageID(), 13 | 1, 14 | ) 15 | 16 | return event 17 | } 18 | -------------------------------------------------------------------------------- /src/customeraccounts/hexagon/application/domain/customer/Register_test.go: -------------------------------------------------------------------------------- 1 | package customer_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/AntonStoeckl/go-iddd/src/customeraccounts/hexagon/application/domain" 7 | "github.com/AntonStoeckl/go-iddd/src/customeraccounts/hexagon/application/domain/customer" 8 | "github.com/AntonStoeckl/go-iddd/src/customeraccounts/hexagon/application/domain/customer/value" 9 | . "github.com/smartystreets/goconvey/convey" 10 | ) 11 | 12 | func TestRegister(t *testing.T) { 13 | Convey("Prepare test artifacts", t, func() { 14 | customerID := value.GenerateCustomerID() 15 | emailAddress, err := value.BuildUnconfirmedEmailAddress("kevin@ball.com") 16 | So(err, ShouldBeNil) 17 | personName, err := value.BuildPersonName("Kevin", "Ball") 18 | So(err, ShouldBeNil) 19 | 20 | command := domain.BuildRegisterCustomer( 21 | customerID, 22 | emailAddress, 23 | personName, 24 | ) 25 | 26 | Convey("\nSCENARIO: Register a Customer", func() { 27 | Convey("When RegisterCustomer", func() { 28 | event := customer.Register(command) 29 | 30 | Convey("Then CustomerRegistered", func() { 31 | So(event.CustomerID().Equals(customerID), ShouldBeTrue) 32 | So(event.EmailAddress().Equals(emailAddress), ShouldBeTrue) 33 | So(event.EmailAddress().ConfirmationHash().Equals(emailAddress.ConfirmationHash()), ShouldBeTrue) 34 | So(event.PersonName().Equals(personName), ShouldBeTrue) 35 | So(event.IsFailureEvent(), ShouldBeFalse) 36 | So(event.FailureReason(), ShouldBeNil) 37 | So(event.Meta().CausationID(), ShouldEqual, command.MessageID().String()) 38 | So(event.Meta().MessageID(), ShouldNotBeEmpty) 39 | So(event.Meta().StreamVersion(), ShouldEqual, uint(1)) 40 | }) 41 | }) 42 | }) 43 | }) 44 | } 45 | -------------------------------------------------------------------------------- /src/customeraccounts/hexagon/application/domain/customer/View.go: -------------------------------------------------------------------------------- 1 | package customer 2 | 3 | import ( 4 | "github.com/AntonStoeckl/go-iddd/src/customeraccounts/hexagon/application/domain/customer/value" 5 | "github.com/AntonStoeckl/go-iddd/src/shared/es" 6 | ) 7 | 8 | type View struct { 9 | ID string 10 | EmailAddress string 11 | IsEmailAddressConfirmed bool 12 | GivenName string 13 | FamilyName string 14 | IsDeleted bool 15 | Version uint 16 | } 17 | 18 | func BuildViewFrom(eventStream es.EventStream) View { 19 | customer := buildCurrentStateFrom(eventStream) 20 | 21 | customerView := View{ 22 | ID: customer.id.String(), 23 | EmailAddress: customer.emailAddress.String(), 24 | GivenName: customer.personName.GivenName(), 25 | FamilyName: customer.personName.FamilyName(), 26 | IsDeleted: customer.isDeleted, 27 | Version: customer.currentStreamVersion, 28 | } 29 | 30 | switch customer.emailAddress.(type) { 31 | case value.ConfirmedEmailAddress: 32 | customerView.IsEmailAddressConfirmed = true 33 | case value.UnconfirmedEmailAddress: 34 | customerView.IsEmailAddressConfirmed = false 35 | default: 36 | // until Go has "union types" we need to use an interface and this case could exist - we don't want to hide it 37 | panic("BuildViewFrom(eventStream): emailAddress is neither UnconfirmedEmailAddress nor ConfirmedEmailAddress") 38 | } 39 | 40 | return customerView 41 | } 42 | -------------------------------------------------------------------------------- /src/customeraccounts/hexagon/application/domain/customer/assertNotDeleted.go: -------------------------------------------------------------------------------- 1 | package customer 2 | 3 | import ( 4 | "github.com/AntonStoeckl/go-iddd/src/shared" 5 | "github.com/cockroachdb/errors" 6 | ) 7 | 8 | func assertNotDeleted(currentState currentState) error { 9 | if currentState.isDeleted { 10 | return errors.Mark(errors.New("customer was deleted"), shared.ErrNotFound) 11 | } 12 | 13 | return nil 14 | } 15 | -------------------------------------------------------------------------------- /src/customeraccounts/hexagon/application/domain/customer/currentState.go: -------------------------------------------------------------------------------- 1 | package customer 2 | 3 | import ( 4 | "github.com/AntonStoeckl/go-iddd/src/customeraccounts/hexagon/application/domain" 5 | "github.com/AntonStoeckl/go-iddd/src/customeraccounts/hexagon/application/domain/customer/value" 6 | "github.com/AntonStoeckl/go-iddd/src/shared/es" 7 | ) 8 | 9 | type currentState struct { 10 | id value.CustomerID 11 | personName value.PersonName 12 | emailAddress value.EmailAddress 13 | isDeleted bool 14 | currentStreamVersion uint 15 | } 16 | 17 | func buildCurrentStateFrom(eventStream es.EventStream) currentState { 18 | customer := currentState{} 19 | 20 | for _, event := range eventStream { 21 | switch actualEvent := event.(type) { 22 | case domain.CustomerRegistered: 23 | customer.id = actualEvent.CustomerID() 24 | customer.personName = actualEvent.PersonName() 25 | customer.emailAddress = actualEvent.EmailAddress() 26 | case domain.CustomerEmailAddressConfirmed: 27 | customer.emailAddress = actualEvent.EmailAddress() 28 | case domain.CustomerEmailAddressChanged: 29 | customer.emailAddress = actualEvent.EmailAddress() 30 | case domain.CustomerNameChanged: 31 | customer.personName = actualEvent.PersonName() 32 | case domain.CustomerDeleted: 33 | customer.isDeleted = true 34 | case domain.CustomerEmailAddressConfirmationFailed: 35 | // nothing to project here 36 | default: 37 | // until Go has "sum types" we need to use an interface (Event) and this case could exist - we don't want to hide it 38 | panic("buildCurrentStateFrom(eventStream): unknown event " + event.Meta().EventName()) 39 | } 40 | 41 | customer.currentStreamVersion = event.Meta().StreamVersion() 42 | } 43 | 44 | return customer 45 | } 46 | -------------------------------------------------------------------------------- /src/customeraccounts/hexagon/application/domain/customer/value/ConfirmationHash.go: -------------------------------------------------------------------------------- 1 | package value 2 | 3 | import ( 4 | "crypto/sha256" 5 | "fmt" 6 | "math/rand" 7 | "strconv" 8 | "time" 9 | 10 | "github.com/AntonStoeckl/go-iddd/src/shared" 11 | "github.com/cockroachdb/errors" 12 | ) 13 | 14 | type ConfirmationHash string 15 | 16 | func GenerateConfirmationHash(using string) ConfirmationHash { 17 | //nolint:gosec // no super secure random number needed here 18 | randomInt := rand.New(rand.NewSource(time.Now().UnixNano())).Int() 19 | sha256Sum := sha256.Sum256([]byte(using + strconv.Itoa(randomInt))) 20 | value := fmt.Sprintf("%x", sha256Sum) 21 | 22 | return ConfirmationHash(value) 23 | } 24 | 25 | func BuildConfirmationHash(input string) (ConfirmationHash, error) { 26 | if input == "" { 27 | err := errors.New("empty input for confirmationHash") 28 | err = shared.MarkAndWrapError(err, shared.ErrInputIsInvalid, "BuildConfirmationHash") 29 | 30 | return "", err 31 | } 32 | 33 | confirmationHash := ConfirmationHash(input) 34 | 35 | return confirmationHash, nil 36 | } 37 | 38 | func RebuildConfirmationHash(input string) ConfirmationHash { 39 | return ConfirmationHash(input) 40 | } 41 | 42 | func (confirmationHash ConfirmationHash) String() string { 43 | return string(confirmationHash) 44 | } 45 | 46 | func (confirmationHash ConfirmationHash) Equals(other ConfirmationHash) bool { 47 | return confirmationHash.String() == other.String() 48 | } 49 | -------------------------------------------------------------------------------- /src/customeraccounts/hexagon/application/domain/customer/value/ConfirmedEmailAddress.go: -------------------------------------------------------------------------------- 1 | package value 2 | 3 | import ( 4 | "github.com/AntonStoeckl/go-iddd/src/shared" 5 | "github.com/cockroachdb/errors" 6 | ) 7 | 8 | type ConfirmedEmailAddress string 9 | 10 | func ConfirmEmailAddressWithHash( 11 | emailAddress UnconfirmedEmailAddress, 12 | confirmationHash ConfirmationHash, 13 | ) (ConfirmedEmailAddress, error) { 14 | 15 | if !emailAddress.confirmationHash.Equals(confirmationHash) { 16 | return "", errors.Mark( 17 | errors.New("confirmEmailAddressWithHash: wrong confirmation hash supplied"), 18 | shared.ErrDomainConstraintsViolation, 19 | ) 20 | } 21 | 22 | return ConfirmedEmailAddress(emailAddress.String()), nil 23 | } 24 | 25 | func RebuildConfirmedEmailAddress(input string) ConfirmedEmailAddress { 26 | return ConfirmedEmailAddress(input) 27 | } 28 | 29 | func (emailAddress ConfirmedEmailAddress) String() string { 30 | return string(emailAddress) 31 | } 32 | 33 | func (emailAddress ConfirmedEmailAddress) Equals(other EmailAddress) bool { 34 | return emailAddress.String() == other.String() 35 | } 36 | -------------------------------------------------------------------------------- /src/customeraccounts/hexagon/application/domain/customer/value/CustomerID.go: -------------------------------------------------------------------------------- 1 | package value 2 | 3 | import ( 4 | "github.com/AntonStoeckl/go-iddd/src/shared" 5 | "github.com/cockroachdb/errors" 6 | "github.com/google/uuid" 7 | ) 8 | 9 | type CustomerID string 10 | 11 | func GenerateCustomerID() CustomerID { 12 | return CustomerID(uuid.New().String()) 13 | } 14 | 15 | func BuildCustomerID(value string) (CustomerID, error) { 16 | if value == "" { 17 | err := errors.New("empty input for CustomerID") 18 | err = shared.MarkAndWrapError(err, shared.ErrInputIsInvalid, "BuildCustomerID") 19 | 20 | return "", err 21 | } 22 | 23 | id := CustomerID(value) 24 | 25 | return id, nil 26 | } 27 | 28 | func RebuildCustomerID(value string) CustomerID { 29 | return CustomerID(value) 30 | } 31 | 32 | func (id CustomerID) String() string { 33 | return string(id) 34 | } 35 | 36 | func (id CustomerID) Equals(other CustomerID) bool { 37 | return id.String() == other.String() 38 | } 39 | -------------------------------------------------------------------------------- /src/customeraccounts/hexagon/application/domain/customer/value/EmailAddress.go: -------------------------------------------------------------------------------- 1 | package value 2 | 3 | type EmailAddress interface { 4 | String() string 5 | Equals(other EmailAddress) bool 6 | } 7 | -------------------------------------------------------------------------------- /src/customeraccounts/hexagon/application/domain/customer/value/PersonName.go: -------------------------------------------------------------------------------- 1 | package value 2 | 3 | import ( 4 | "github.com/AntonStoeckl/go-iddd/src/shared" 5 | "github.com/cockroachdb/errors" 6 | ) 7 | 8 | type PersonName struct { 9 | givenName string 10 | familyName string 11 | } 12 | 13 | func BuildPersonName(givenName, familyName string) (PersonName, error) { 14 | wrapWithMsg := "BuildPersonName" 15 | 16 | if familyName == "" { 17 | err := errors.New("empty input for familyName") 18 | err = shared.MarkAndWrapError(err, shared.ErrInputIsInvalid, wrapWithMsg) 19 | 20 | return PersonName{}, err 21 | } 22 | 23 | if givenName == "" { 24 | err := errors.New("empty input for givenName") 25 | err = shared.MarkAndWrapError(err, shared.ErrInputIsInvalid, wrapWithMsg) 26 | 27 | return PersonName{}, err 28 | } 29 | 30 | personName := PersonName{ 31 | givenName: givenName, 32 | familyName: familyName, 33 | } 34 | 35 | return personName, nil 36 | } 37 | 38 | func RebuildPersonName(givenName, familyName string) PersonName { 39 | personName := PersonName{ 40 | givenName: givenName, 41 | familyName: familyName, 42 | } 43 | 44 | return personName 45 | } 46 | 47 | func (personName PersonName) GivenName() string { 48 | return personName.givenName 49 | } 50 | 51 | func (personName PersonName) FamilyName() string { 52 | return personName.familyName 53 | } 54 | 55 | func (personName PersonName) Equals(other PersonName) bool { 56 | if personName.GivenName() != other.GivenName() { 57 | return false 58 | } 59 | 60 | if personName.FamilyName() != other.FamilyName() { 61 | return false 62 | } 63 | 64 | return true 65 | } 66 | -------------------------------------------------------------------------------- /src/customeraccounts/hexagon/application/domain/customer/value/PersonName_test.go: -------------------------------------------------------------------------------- 1 | package value_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/AntonStoeckl/go-iddd/src/customeraccounts/hexagon/application/domain/customer/value" 7 | . "github.com/smartystreets/goconvey/convey" 8 | ) 9 | 10 | func TestPersonName_Equals(t *testing.T) { 11 | Convey("Given a PersonName", t, func() { 12 | personName := value.RebuildPersonName("Lib", "Gallagher") 13 | 14 | Convey("When it is compared with an identical PersonName", func() { 15 | identicalPersonName := value.RebuildPersonName(personName.GivenName(), personName.FamilyName()) 16 | isEqual := personName.Equals(identicalPersonName) 17 | 18 | Convey("Then it should be equal", func() { 19 | So(isEqual, ShouldBeTrue) 20 | }) 21 | }) 22 | 23 | Convey("When it is compared with another PersonName with different givenName", func() { 24 | differentPersonName := value.RebuildPersonName("Phillip", personName.FamilyName()) 25 | isEqual := personName.Equals(differentPersonName) 26 | 27 | Convey("Then it should not be equal", func() { 28 | So(isEqual, ShouldBeFalse) 29 | }) 30 | }) 31 | 32 | Convey("When it is compared with another PersonName with different familyName", func() { 33 | differentPersonName := value.RebuildPersonName(personName.GivenName(), "Jackson") 34 | isEqual := personName.Equals(differentPersonName) 35 | 36 | Convey("Then it should not be equal", func() { 37 | So(isEqual, ShouldBeFalse) 38 | }) 39 | }) 40 | }) 41 | } 42 | -------------------------------------------------------------------------------- /src/customeraccounts/hexagon/application/domain/customer/value/UnconfirmedEmailAddress.go: -------------------------------------------------------------------------------- 1 | package value 2 | 3 | import ( 4 | "regexp" 5 | 6 | "github.com/AntonStoeckl/go-iddd/src/shared" 7 | "github.com/cockroachdb/errors" 8 | ) 9 | 10 | var ( 11 | emailAddressRegExp = regexp.MustCompile(`^\S+@\S+\.\w{2,}$`) 12 | ) 13 | 14 | type UnconfirmedEmailAddress struct { 15 | value string 16 | confirmationHash ConfirmationHash 17 | } 18 | 19 | func BuildUnconfirmedEmailAddress(input string) (UnconfirmedEmailAddress, error) { 20 | if matched := emailAddressRegExp.MatchString(input); !matched { 21 | err := errors.New("input has invalid format") 22 | err = shared.MarkAndWrapError(err, shared.ErrInputIsInvalid, "UnconfirmedEmailAddress") 23 | 24 | return UnconfirmedEmailAddress{}, err 25 | } 26 | 27 | emailAddress := UnconfirmedEmailAddress{ 28 | value: input, 29 | confirmationHash: GenerateConfirmationHash(input), 30 | } 31 | 32 | return emailAddress, nil 33 | } 34 | 35 | func RebuildUnconfirmedEmailAddress(input, hash string) UnconfirmedEmailAddress { 36 | return UnconfirmedEmailAddress{ 37 | value: input, 38 | confirmationHash: RebuildConfirmationHash(hash), 39 | } 40 | } 41 | 42 | func (emailAddress UnconfirmedEmailAddress) String() string { 43 | return emailAddress.value 44 | } 45 | 46 | func (emailAddress UnconfirmedEmailAddress) ConfirmationHash() ConfirmationHash { 47 | return emailAddress.confirmationHash 48 | } 49 | 50 | func (emailAddress UnconfirmedEmailAddress) Equals(other EmailAddress) bool { 51 | return emailAddress.String() == other.String() 52 | } 53 | -------------------------------------------------------------------------------- /src/customeraccounts/infrastructure/adapter/grpc/CustomerServer.go: -------------------------------------------------------------------------------- 1 | package customergrpc 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/AntonStoeckl/go-iddd/src/customeraccounts/hexagon" 7 | "github.com/AntonStoeckl/go-iddd/src/customeraccounts/hexagon/application/domain/customer/value" 8 | customergrpcproto "github.com/AntonStoeckl/go-iddd/src/customeraccounts/infrastructure/adapter/grpc/proto" 9 | "github.com/golang/protobuf/ptypes/empty" 10 | ) 11 | 12 | type customerServer struct { 13 | register hexagon.ForRegisteringCustomers 14 | confirmEmailAddress hexagon.ForConfirmingCustomerEmailAddresses 15 | changeEmailAddress hexagon.ForChangingCustomerEmailAddresses 16 | changeName hexagon.ForChangingCustomerNames 17 | delete hexagon.ForDeletingCustomers 18 | retrieveView hexagon.ForRetrievingCustomerViews 19 | } 20 | 21 | func NewCustomerServer( 22 | register hexagon.ForRegisteringCustomers, 23 | confirmEmailAddress hexagon.ForConfirmingCustomerEmailAddresses, 24 | changeEmailAddress hexagon.ForChangingCustomerEmailAddresses, 25 | changeName hexagon.ForChangingCustomerNames, 26 | delete hexagon.ForDeletingCustomers, //nolint:gocritic // false positive (shadowing of predeclared identifier: delete) 27 | retrieveView hexagon.ForRetrievingCustomerViews, 28 | ) customergrpcproto.CustomerServer { 29 | server := &customerServer{ 30 | register: register, 31 | confirmEmailAddress: confirmEmailAddress, 32 | changeEmailAddress: changeEmailAddress, 33 | changeName: changeName, 34 | delete: delete, 35 | retrieveView: retrieveView, 36 | } 37 | 38 | return server 39 | } 40 | 41 | func (server *customerServer) Register( 42 | _ context.Context, 43 | req *customergrpcproto.RegisterRequest, 44 | ) (*customergrpcproto.RegisterResponse, error) { 45 | 46 | customerIDValue := value.GenerateCustomerID() 47 | 48 | if err := server.register(customerIDValue, req.EmailAddress, req.GivenName, req.FamilyName); err != nil { 49 | return nil, MapToGRPCErrors(err) 50 | } 51 | 52 | return &customergrpcproto.RegisterResponse{Id: customerIDValue.String()}, nil 53 | } 54 | 55 | func (server *customerServer) ConfirmEmailAddress( 56 | _ context.Context, 57 | req *customergrpcproto.ConfirmEmailAddressRequest, 58 | ) (*empty.Empty, error) { 59 | 60 | if err := server.confirmEmailAddress(req.Id, req.ConfirmationHash); err != nil { 61 | return nil, MapToGRPCErrors(err) 62 | } 63 | 64 | return &empty.Empty{}, nil 65 | } 66 | 67 | func (server *customerServer) ChangeEmailAddress( 68 | _ context.Context, 69 | req *customergrpcproto.ChangeEmailAddressRequest, 70 | ) (*empty.Empty, error) { 71 | 72 | if err := server.changeEmailAddress(req.Id, req.EmailAddress); err != nil { 73 | return nil, MapToGRPCErrors(err) 74 | } 75 | 76 | return &empty.Empty{}, nil 77 | } 78 | 79 | func (server *customerServer) ChangeName( 80 | _ context.Context, 81 | req *customergrpcproto.ChangeNameRequest, 82 | ) (*empty.Empty, error) { 83 | 84 | if err := server.changeName(req.Id, req.GivenName, req.FamilyName); err != nil { 85 | return nil, MapToGRPCErrors(err) 86 | } 87 | 88 | return &empty.Empty{}, nil 89 | } 90 | 91 | func (server *customerServer) Delete( 92 | _ context.Context, 93 | req *customergrpcproto.DeleteRequest, 94 | ) (*empty.Empty, error) { 95 | 96 | if err := server.delete(req.Id); err != nil { 97 | return nil, MapToGRPCErrors(err) 98 | } 99 | 100 | return &empty.Empty{}, nil 101 | } 102 | 103 | func (server *customerServer) RetrieveView( 104 | _ context.Context, 105 | req *customergrpcproto.RetrieveViewRequest, 106 | ) (*customergrpcproto.RetrieveViewResponse, error) { 107 | 108 | view, err := server.retrieveView(req.Id) 109 | if err != nil { 110 | return nil, MapToGRPCErrors(err) 111 | } 112 | 113 | response := &customergrpcproto.RetrieveViewResponse{ 114 | EmailAddress: view.EmailAddress, 115 | IsEmailAddressConfirmed: view.IsEmailAddressConfirmed, 116 | GivenName: view.GivenName, 117 | FamilyName: view.FamilyName, 118 | Version: uint64(view.Version), 119 | } 120 | 121 | return response, nil 122 | } 123 | -------------------------------------------------------------------------------- /src/customeraccounts/infrastructure/adapter/grpc/CustomerServer_test.go: -------------------------------------------------------------------------------- 1 | package customergrpc_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/AntonStoeckl/go-iddd/src/customeraccounts/hexagon/application/domain/customer" 8 | "github.com/AntonStoeckl/go-iddd/src/customeraccounts/hexagon/application/domain/customer/value" 9 | customergrpc "github.com/AntonStoeckl/go-iddd/src/customeraccounts/infrastructure/adapter/grpc" 10 | customergrpcproto "github.com/AntonStoeckl/go-iddd/src/customeraccounts/infrastructure/adapter/grpc/proto" 11 | "github.com/AntonStoeckl/go-iddd/src/shared" 12 | "github.com/cockroachdb/errors" 13 | "github.com/golang/protobuf/ptypes/empty" 14 | . "github.com/smartystreets/goconvey/convey" 15 | "google.golang.org/grpc/codes" 16 | "google.golang.org/grpc/status" 17 | ) 18 | 19 | var generatedID value.CustomerID 20 | var mockedView = customer.View{ 21 | ID: generatedID.String(), 22 | EmailAddress: "fiona@gallagher.net", 23 | IsEmailAddressConfirmed: true, 24 | GivenName: "Fiona", 25 | FamilyName: "Gallagher", 26 | IsDeleted: false, 27 | Version: 2, 28 | } 29 | var expectedErrCode = codes.InvalidArgument 30 | var expectedErrMsg = "invalid input" 31 | 32 | func TestGRPCServer(t *testing.T) { 33 | Convey("Prepare test artifacts", t, func() { 34 | successCustomerServer := buildSuccessCustomerServer() 35 | failureCustomerServer := buildFailureCustomerServer() 36 | 37 | Convey("Usecase: Register", func() { 38 | Convey("Given the application will return success", func() { 39 | Convey("When the request is handled", func() { 40 | res, err := successCustomerServer.Register( 41 | context.Background(), 42 | &customergrpcproto.RegisterRequest{}, 43 | ) 44 | 45 | Convey("Then it should succeed", func() { 46 | So(err, ShouldBeNil) 47 | So(res, ShouldNotBeNil) 48 | So(res.Id, ShouldResemble, generatedID.String()) 49 | }) 50 | }) 51 | }) 52 | 53 | Convey("Given the application will return an error", func() { 54 | Convey("When the request is handled", func() { 55 | res, err := failureCustomerServer.Register( 56 | context.Background(), 57 | &customergrpcproto.RegisterRequest{}, 58 | ) 59 | 60 | Convey("Then it should fail with the exptected error", func() { 61 | So(err, ShouldBeError) 62 | So(err, ShouldResemble, status.Error(expectedErrCode, expectedErrMsg)) 63 | So(res, ShouldBeNil) 64 | }) 65 | }) 66 | }) 67 | }) 68 | 69 | Convey("\nUsecase: ConfirmEmailAddress", func() { 70 | Convey("Given the application will return success", func() { 71 | Convey("When the request is handled", func() { 72 | res, err := successCustomerServer.ConfirmEmailAddress( 73 | context.Background(), 74 | &customergrpcproto.ConfirmEmailAddressRequest{}, 75 | ) 76 | 77 | thenItShouldSuccees(res, err) 78 | }) 79 | }) 80 | 81 | Convey("Given the application will return an error", func() { 82 | Convey("When the request is handled", func() { 83 | res, err := failureCustomerServer.ConfirmEmailAddress( 84 | context.Background(), 85 | &customergrpcproto.ConfirmEmailAddressRequest{}, 86 | ) 87 | 88 | thenItShouldFailWithTheExpectedError(res, err) 89 | }) 90 | }) 91 | }) 92 | 93 | Convey("\nUsecase: ChangeEmailAddress", func() { 94 | Convey("Given the application will return success", func() { 95 | Convey("When the request is handled", func() { 96 | res, err := successCustomerServer.ChangeEmailAddress( 97 | context.Background(), 98 | &customergrpcproto.ChangeEmailAddressRequest{}, 99 | ) 100 | 101 | thenItShouldSuccees(res, err) 102 | }) 103 | }) 104 | 105 | Convey("Given the application will return an error", func() { 106 | Convey("When the request is handled", func() { 107 | res, err := failureCustomerServer.ChangeEmailAddress( 108 | context.Background(), 109 | &customergrpcproto.ChangeEmailAddressRequest{}, 110 | ) 111 | 112 | thenItShouldFailWithTheExpectedError(res, err) 113 | }) 114 | }) 115 | }) 116 | 117 | Convey("\nUsecase: ChangeName", func() { 118 | Convey("Given the application will return success", func() { 119 | Convey("When the request is handled", func() { 120 | res, err := successCustomerServer.ChangeName( 121 | context.Background(), 122 | &customergrpcproto.ChangeNameRequest{}, 123 | ) 124 | 125 | thenItShouldSuccees(res, err) 126 | }) 127 | }) 128 | 129 | Convey("Given the application will return an error", func() { 130 | Convey("When the request is handled", func() { 131 | res, err := failureCustomerServer.ChangeName( 132 | context.Background(), 133 | &customergrpcproto.ChangeNameRequest{}, 134 | ) 135 | 136 | thenItShouldFailWithTheExpectedError(res, err) 137 | }) 138 | }) 139 | }) 140 | 141 | Convey("\nUsecase: Delete", func() { 142 | Convey("Given the application will return success", func() { 143 | Convey("When the request is handled", func() { 144 | res, err := successCustomerServer.Delete( 145 | context.Background(), 146 | &customergrpcproto.DeleteRequest{}, 147 | ) 148 | 149 | thenItShouldSuccees(res, err) 150 | }) 151 | }) 152 | 153 | Convey("Given the application will return an error", func() { 154 | Convey("When the request is handled", func() { 155 | res, err := failureCustomerServer.Delete( 156 | context.Background(), 157 | &customergrpcproto.DeleteRequest{}, 158 | ) 159 | 160 | thenItShouldFailWithTheExpectedError(res, err) 161 | }) 162 | }) 163 | }) 164 | 165 | Convey("\nUsecase: RetrieveView", func() { 166 | Convey("Given the application will return success", func() { 167 | Convey("When the request is handled", func() { 168 | res, err := successCustomerServer.RetrieveView( 169 | context.Background(), 170 | &customergrpcproto.RetrieveViewRequest{}, 171 | ) 172 | 173 | Convey("Then it should succeed", func() { 174 | So(err, ShouldBeNil) 175 | So(res, ShouldNotBeNil) 176 | 177 | expectedRes := &customergrpcproto.RetrieveViewResponse{ 178 | EmailAddress: mockedView.EmailAddress, 179 | IsEmailAddressConfirmed: mockedView.IsEmailAddressConfirmed, 180 | GivenName: mockedView.GivenName, 181 | FamilyName: mockedView.FamilyName, 182 | Version: uint64(mockedView.Version), 183 | } 184 | 185 | So(res, ShouldResemble, expectedRes) 186 | }) 187 | }) 188 | }) 189 | 190 | Convey("Given the application will return an error", func() { 191 | Convey("When the request is handled", func() { 192 | res, err := failureCustomerServer.RetrieveView( 193 | context.Background(), 194 | &customergrpcproto.RetrieveViewRequest{}, 195 | ) 196 | 197 | Convey("Then it should fail with the exptected error", func() { 198 | So(err, ShouldBeError) 199 | So(err, ShouldResemble, status.Error(expectedErrCode, expectedErrMsg)) 200 | So(res, ShouldBeNil) 201 | }) 202 | }) 203 | }) 204 | }) 205 | }) 206 | } 207 | 208 | func thenItShouldSuccees(res *empty.Empty, err error) { 209 | Convey("Then it should succeed", func() { 210 | So(err, ShouldBeNil) 211 | So(res, ShouldResemble, &empty.Empty{}) 212 | }) 213 | } 214 | 215 | func thenItShouldFailWithTheExpectedError(res *empty.Empty, err error) { 216 | Convey("Then it should fail with the exptected error", func() { 217 | So(err, ShouldBeError) 218 | So(err, ShouldResemble, status.Error(expectedErrCode, expectedErrMsg)) 219 | So(res, ShouldBeNil) 220 | }) 221 | } 222 | 223 | func buildSuccessCustomerServer() customergrpcproto.CustomerServer { 224 | customerGRPCServer := customergrpc.NewCustomerServer( 225 | func(customerIDValue value.CustomerID, emailAddress, givenName, familyName string) error { 226 | generatedID = customerIDValue 227 | return nil 228 | }, 229 | func(customerID, confirmationHash string) error { 230 | return nil 231 | }, 232 | func(customerID, emailAddress string) error { 233 | return nil 234 | }, 235 | func(customerID, givenName, familyName string) error { 236 | return nil 237 | }, 238 | func(customerID string) error { 239 | return nil 240 | }, 241 | func(customerID string) (customer.View, error) { 242 | return mockedView, nil 243 | }, 244 | ) 245 | 246 | return customerGRPCServer 247 | } 248 | 249 | func buildFailureCustomerServer() customergrpcproto.CustomerServer { 250 | mockedView := customer.View{} 251 | mockedErr := errors.Mark(errors.New(expectedErrMsg), shared.ErrInputIsInvalid) 252 | 253 | customerGRPCServer := customergrpc.NewCustomerServer( 254 | func(customerIDValue value.CustomerID, emailAddress, givenName, familyName string) error { 255 | return mockedErr 256 | }, 257 | func(customerID, confirmationHash string) error { 258 | return mockedErr 259 | }, 260 | func(customerID, emailAddress string) error { 261 | return mockedErr 262 | }, 263 | func(customerID, givenName, familyName string) error { 264 | return mockedErr 265 | }, 266 | func(customerID string) error { 267 | return mockedErr 268 | }, 269 | func(customerID string) (customer.View, error) { 270 | return mockedView, mockedErr 271 | }, 272 | ) 273 | 274 | return customerGRPCServer 275 | } 276 | -------------------------------------------------------------------------------- /src/customeraccounts/infrastructure/adapter/grpc/MapToGRPCErrors.go: -------------------------------------------------------------------------------- 1 | package customergrpc 2 | 3 | import ( 4 | "github.com/AntonStoeckl/go-iddd/src/shared" 5 | "github.com/cockroachdb/errors" 6 | "google.golang.org/grpc/codes" 7 | "google.golang.org/grpc/status" 8 | ) 9 | 10 | func MapToGRPCErrors(appErr error) error { 11 | var code codes.Code 12 | 13 | switch { 14 | case errors.Is(appErr, shared.ErrInputIsInvalid): 15 | code = codes.InvalidArgument 16 | case errors.Is(appErr, shared.ErrNotFound): 17 | code = codes.NotFound 18 | case errors.Is(appErr, shared.ErrDuplicate): 19 | code = codes.AlreadyExists 20 | 21 | case errors.Is(appErr, shared.ErrDomainConstraintsViolation): 22 | code = codes.FailedPrecondition 23 | 24 | case errors.Is(appErr, shared.ErrMaxRetriesExceeded): 25 | code = codes.Aborted 26 | case errors.Is(appErr, shared.ErrConcurrencyConflict): 27 | code = codes.Aborted 28 | 29 | default: 30 | code = codes.Internal 31 | } 32 | 33 | return status.Errorf(code, "%s", errors.Cause(appErr)) 34 | } 35 | -------------------------------------------------------------------------------- /src/customeraccounts/infrastructure/adapter/grpc/proto/customer.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | package customergrpcproto; 3 | 4 | import "google/protobuf/empty.proto"; 5 | import "google/api/annotations.proto"; 6 | 7 | service Customer { 8 | rpc Register (RegisterRequest) returns (RegisterResponse) { 9 | option (google.api.http) = { 10 | post: "/v1/customer" 11 | body: "*" 12 | }; 13 | } 14 | 15 | rpc ConfirmEmailAddress (ConfirmEmailAddressRequest) returns (google.protobuf.Empty) { 16 | option (google.api.http) = { 17 | put: "/v1/customer/{id}/emailaddress/confirm" 18 | body: "*" 19 | }; 20 | } 21 | 22 | rpc ChangeEmailAddress (ChangeEmailAddressRequest) returns (google.protobuf.Empty) { 23 | option (google.api.http) = { 24 | put: "/v1/customer/{id}/emailaddress" 25 | body: "*" 26 | }; 27 | } 28 | 29 | rpc ChangeName (ChangeNameRequest) returns (google.protobuf.Empty) { 30 | option (google.api.http) = { 31 | put: "/v1/customer/{id}/name" 32 | body: "*" 33 | }; 34 | } 35 | 36 | rpc Delete (DeleteRequest) returns (google.protobuf.Empty) { 37 | option (google.api.http) = { 38 | delete: "/v1/customer/{id}" 39 | }; 40 | } 41 | 42 | rpc RetrieveView (RetrieveViewRequest) returns (RetrieveViewResponse) { 43 | option (google.api.http) = { 44 | get: "/v1/customer/{id}" 45 | }; 46 | } 47 | } 48 | 49 | // Register Customer 50 | 51 | message RegisterRequest { 52 | string emailAddress = 1; 53 | string givenName = 2; 54 | string familyName = 3; 55 | } 56 | 57 | message RegisterResponse { 58 | string id = 1; 59 | } 60 | 61 | // Confirm Customer EmailAddress 62 | 63 | message ConfirmEmailAddressRequest { 64 | string id = 1; 65 | string confirmationHash = 2; 66 | } 67 | 68 | // Change Customer EmailAddress 69 | 70 | message ChangeEmailAddressRequest { 71 | string id = 1; 72 | string emailAddress = 2; 73 | } 74 | 75 | // Change Customer Name 76 | 77 | message ChangeNameRequest { 78 | string id = 1; 79 | string givenName = 2; 80 | string familyName = 3; 81 | } 82 | 83 | // Delete Customer 84 | 85 | message DeleteRequest { 86 | string id = 1; 87 | } 88 | 89 | // Retrieve Customer View 90 | 91 | message RetrieveViewRequest { 92 | string id = 1; 93 | } 94 | 95 | message RetrieveViewResponse { 96 | string emailAddress = 1; 97 | bool isEmailAddressConfirmed = 2; 98 | string givenName = 3; 99 | string familyName = 4; 100 | uint64 version = 5; 101 | } -------------------------------------------------------------------------------- /src/customeraccounts/infrastructure/adapter/postgres/CustomerEventStore.go: -------------------------------------------------------------------------------- 1 | package postgres 2 | 3 | import ( 4 | "database/sql" 5 | "math" 6 | 7 | "github.com/AntonStoeckl/go-iddd/src/customeraccounts/hexagon/application/domain" 8 | "github.com/AntonStoeckl/go-iddd/src/customeraccounts/hexagon/application/domain/customer/value" 9 | "github.com/AntonStoeckl/go-iddd/src/shared" 10 | "github.com/AntonStoeckl/go-iddd/src/shared/es" 11 | "github.com/cockroachdb/errors" 12 | ) 13 | 14 | const streamPrefix = "customer" 15 | 16 | type forRetrievingEventStreams func(streamID es.StreamID, fromVersion uint, maxEvents uint, db *sql.DB) (es.EventStream, error) 17 | type forAppendingEventsToStreams func(streamID es.StreamID, events []es.DomainEvent, tx *sql.Tx) error 18 | type forPurgingEventStreams func(streamID es.StreamID, tx *sql.Tx) error 19 | type forAssertingUniqueEmailAddresses func(recordedEvents []es.DomainEvent, tx *sql.Tx) error 20 | type forPurgingUniqueEmailAddresses func(customerID value.CustomerID, tx *sql.Tx) error 21 | 22 | type CustomerEventStore struct { 23 | db *sql.DB 24 | retrieveEventStream forRetrievingEventStreams 25 | appendEventsToStream forAppendingEventsToStreams 26 | purgeEventStream forPurgingEventStreams 27 | assertUniqueEmailAddress forAssertingUniqueEmailAddresses 28 | purgeUniqueEmailAddress forPurgingUniqueEmailAddresses 29 | } 30 | 31 | func NewCustomerEventStore( 32 | db *sql.DB, 33 | retrieveEventStream forRetrievingEventStreams, 34 | appendEventsToStream forAppendingEventsToStreams, 35 | purgeEventStream forPurgingEventStreams, 36 | assertUniqueEmailAddress forAssertingUniqueEmailAddresses, 37 | purgeUniqueEmailAddress forPurgingUniqueEmailAddresses, 38 | ) *CustomerEventStore { 39 | 40 | return &CustomerEventStore{ 41 | db: db, 42 | retrieveEventStream: retrieveEventStream, 43 | appendEventsToStream: appendEventsToStream, 44 | purgeEventStream: purgeEventStream, 45 | assertUniqueEmailAddress: assertUniqueEmailAddress, 46 | purgeUniqueEmailAddress: purgeUniqueEmailAddress, 47 | } 48 | } 49 | 50 | func (s *CustomerEventStore) RetrieveEventStream(id value.CustomerID) (es.EventStream, error) { 51 | wrapWithMsg := "customerEventStore.RetrieveEventStream" 52 | 53 | eventStream, err := s.retrieveEventStream(s.streamID(id), 0, math.MaxUint32, s.db) 54 | if err != nil { 55 | return nil, errors.Wrap(err, wrapWithMsg) 56 | } 57 | 58 | if len(eventStream) == 0 { 59 | err := errors.New("customer not found") 60 | return nil, shared.MarkAndWrapError(err, shared.ErrNotFound, wrapWithMsg) 61 | } 62 | 63 | return eventStream, nil 64 | } 65 | 66 | func (s *CustomerEventStore) StartEventStream(customerRegistered domain.CustomerRegistered) error { 67 | var err error 68 | wrapWithMsg := "customerEventStore.StartEventStream" 69 | 70 | tx, err := s.db.Begin() 71 | if err != nil { 72 | return shared.MarkAndWrapError(err, shared.ErrTechnical, wrapWithMsg) 73 | } 74 | 75 | recordedEvents := []es.DomainEvent{customerRegistered} 76 | 77 | if err = s.assertUniqueEmailAddress(recordedEvents, tx); err != nil { 78 | _ = tx.Rollback() 79 | 80 | return errors.Wrap(err, wrapWithMsg) 81 | } 82 | 83 | streamID := s.streamID(customerRegistered.CustomerID()) 84 | 85 | if err = s.appendEventsToStream(streamID, recordedEvents, tx); err != nil { 86 | _ = tx.Rollback() 87 | 88 | if errors.Is(err, shared.ErrConcurrencyConflict) { 89 | return shared.MarkAndWrapError(errors.New("found duplicate customer"), shared.ErrDuplicate, wrapWithMsg) 90 | } 91 | 92 | return errors.Wrap(err, wrapWithMsg) 93 | } 94 | 95 | if err = tx.Commit(); err != nil { 96 | return shared.MarkAndWrapError(err, shared.ErrTechnical, wrapWithMsg) 97 | } 98 | 99 | return nil 100 | } 101 | 102 | func (s *CustomerEventStore) AppendToEventStream(recordedEvents es.RecordedEvents, id value.CustomerID) error { 103 | var err error 104 | wrapWithMsg := "customerEventStore.AppendToEventStream" 105 | 106 | tx, err := s.db.Begin() 107 | if err != nil { 108 | return shared.MarkAndWrapError(err, shared.ErrTechnical, wrapWithMsg) 109 | } 110 | 111 | if err = s.assertUniqueEmailAddress(recordedEvents, tx); err != nil { 112 | _ = tx.Rollback() 113 | 114 | return errors.Wrap(err, wrapWithMsg) 115 | } 116 | 117 | if err = s.appendEventsToStream(s.streamID(id), recordedEvents, tx); err != nil { 118 | _ = tx.Rollback() 119 | 120 | return errors.Wrap(err, wrapWithMsg) 121 | } 122 | 123 | if err = tx.Commit(); err != nil { 124 | return shared.MarkAndWrapError(err, shared.ErrTechnical, wrapWithMsg) 125 | } 126 | 127 | return nil 128 | } 129 | 130 | func (s *CustomerEventStore) PurgeEventStream(id value.CustomerID) error { 131 | var err error 132 | wrapWithMsg := "customerEventStore.PurgeEventStream" 133 | 134 | tx, err := s.db.Begin() 135 | if err != nil { 136 | return shared.MarkAndWrapError(err, shared.ErrTechnical, wrapWithMsg) 137 | } 138 | 139 | if err = s.purgeUniqueEmailAddress(id, tx); err != nil { 140 | _ = tx.Rollback() 141 | 142 | return errors.Wrap(err, wrapWithMsg) 143 | } 144 | 145 | if err = s.purgeEventStream(s.streamID(id), tx); err != nil { 146 | _ = tx.Rollback() 147 | 148 | return errors.Wrap(err, wrapWithMsg) 149 | } 150 | 151 | if err = tx.Commit(); err != nil { 152 | return shared.MarkAndWrapError(err, shared.ErrTechnical, wrapWithMsg) 153 | } 154 | 155 | return nil 156 | } 157 | 158 | func (s *CustomerEventStore) streamID(id value.CustomerID) es.StreamID { 159 | return es.BuildStreamID(streamPrefix + "-" + id.String()) 160 | } 161 | -------------------------------------------------------------------------------- /src/customeraccounts/infrastructure/adapter/postgres/UniqueCustomerEmailAddresses.go: -------------------------------------------------------------------------------- 1 | package postgres 2 | 3 | import ( 4 | "database/sql" 5 | "strings" 6 | 7 | "github.com/AntonStoeckl/go-iddd/src/shared/es" 8 | 9 | "github.com/AntonStoeckl/go-iddd/src/customeraccounts/hexagon/application/domain/customer" 10 | "github.com/AntonStoeckl/go-iddd/src/customeraccounts/hexagon/application/domain/customer/value" 11 | "github.com/AntonStoeckl/go-iddd/src/shared" 12 | "github.com/cockroachdb/errors" 13 | "github.com/lib/pq" 14 | ) 15 | 16 | type UniqueCustomerEmailAddresses struct { 17 | uniqueEmailAddressesTableName string 18 | buildUniqueEmailAddressAssertions customer.ForBuildingUniqueEmailAddressAssertions 19 | } 20 | 21 | func NewUniqueCustomerEmailAddresses( 22 | uniqueEmailAddressesTableName string, 23 | buildUniqueEmailAddressAssertions customer.ForBuildingUniqueEmailAddressAssertions, 24 | ) *UniqueCustomerEmailAddresses { 25 | 26 | return &UniqueCustomerEmailAddresses{ 27 | uniqueEmailAddressesTableName: uniqueEmailAddressesTableName, 28 | buildUniqueEmailAddressAssertions: buildUniqueEmailAddressAssertions, 29 | } 30 | } 31 | 32 | func (s *UniqueCustomerEmailAddresses) AssertUniqueEmailAddress(recordedEvents []es.DomainEvent, tx *sql.Tx) error { 33 | wrapWithMsg := "assertUniqueEmailAddresse" 34 | 35 | assertions := s.buildUniqueEmailAddressAssertions(recordedEvents...) 36 | 37 | for _, assertion := range assertions { 38 | switch assertion.DesiredAction() { 39 | case customer.ShouldAddUniqueEmailAddress: 40 | if err := s.tryToAdd(assertion.EmailAddressToAdd(), assertion.CustomerID(), tx); err != nil { 41 | return errors.Wrap(err, wrapWithMsg) 42 | } 43 | case customer.ShouldReplaceUniqueEmailAddress: 44 | if err := s.tryToReplace(assertion.EmailAddressToAdd(), assertion.CustomerID(), tx); err != nil { 45 | return errors.Wrap(err, wrapWithMsg) 46 | } 47 | case customer.ShouldRemoveUniqueEmailAddress: 48 | if err := s.remove(assertion.CustomerID(), tx); err != nil { 49 | return errors.Wrap(err, wrapWithMsg) 50 | } 51 | } 52 | } 53 | 54 | return nil 55 | } 56 | 57 | func (s *UniqueCustomerEmailAddresses) PurgeUniqueEmailAddress(customerID value.CustomerID, tx *sql.Tx) error { 58 | return s.remove(customerID, tx) 59 | } 60 | 61 | func (s *UniqueCustomerEmailAddresses) tryToAdd( 62 | emailAddress value.UnconfirmedEmailAddress, 63 | customerID value.CustomerID, 64 | tx *sql.Tx, 65 | ) error { 66 | 67 | queryTemplate := `INSERT INTO %tablename% VALUES ($1, $2)` 68 | query := strings.Replace(queryTemplate, "%tablename%", s.uniqueEmailAddressesTableName, 1) 69 | 70 | _, err := tx.Exec( 71 | query, 72 | emailAddress.String(), 73 | customerID.String(), 74 | ) 75 | 76 | if err != nil { 77 | return s.mapUniqueEmailAddressPostgresErrors(err) 78 | } 79 | 80 | return nil 81 | } 82 | 83 | func (s *UniqueCustomerEmailAddresses) tryToReplace( 84 | emailAddress value.UnconfirmedEmailAddress, 85 | customerID value.CustomerID, 86 | tx *sql.Tx, 87 | ) error { 88 | 89 | queryTemplate := `UPDATE %tablename% set email_address = $1 where customer_id = $2` 90 | query := strings.Replace(queryTemplate, "%tablename%", s.uniqueEmailAddressesTableName, 1) 91 | 92 | _, err := tx.Exec( 93 | query, 94 | emailAddress.String(), 95 | customerID.String(), 96 | ) 97 | 98 | if err != nil { 99 | return s.mapUniqueEmailAddressPostgresErrors(err) 100 | } 101 | 102 | return nil 103 | } 104 | 105 | func (s *UniqueCustomerEmailAddresses) remove( 106 | customerID value.CustomerID, 107 | tx *sql.Tx, 108 | ) error { 109 | 110 | queryTemplate := `DELETE FROM %tablename% where customer_id = $1` 111 | query := strings.Replace(queryTemplate, "%tablename%", s.uniqueEmailAddressesTableName, 1) 112 | 113 | _, err := tx.Exec( 114 | query, 115 | customerID.String(), 116 | ) 117 | 118 | if err != nil { 119 | return s.mapUniqueEmailAddressPostgresErrors(err) 120 | } 121 | 122 | return nil 123 | } 124 | 125 | func (s *UniqueCustomerEmailAddresses) mapUniqueEmailAddressPostgresErrors(err error) error { 126 | // nolint:errorlint // errors.As() suggested, but somehow cockroachdb/errors can't convert this properly 127 | if actualErr, ok := err.(*pq.Error); ok { 128 | if actualErr.Code == "23505" { 129 | return errors.Mark(errors.New("duplicate email address"), shared.ErrDuplicate) 130 | } 131 | } 132 | 133 | return errors.Mark(err, shared.ErrTechnical) // some other DB error (Tx closed, wrong table, ...) 134 | } 135 | -------------------------------------------------------------------------------- /src/customeraccounts/infrastructure/adapter/postgres/database/Migrator.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | 7 | "github.com/AntonStoeckl/go-iddd/src/shared" 8 | "github.com/cockroachdb/errors" 9 | "github.com/golang-migrate/migrate/v4" 10 | "github.com/golang-migrate/migrate/v4/database/postgres" 11 | _ "github.com/golang-migrate/migrate/v4/source/file" // must be imported this way (linter want's a comment) 12 | ) 13 | 14 | type Migrator struct { 15 | postgresMigrator *migrate.Migrate 16 | } 17 | 18 | func NewMigrator(postgresDBConn *sql.DB, migrationsPath string) (*Migrator, error) { 19 | migrator := &Migrator{} 20 | if err := migrator.configure(postgresDBConn, migrationsPath); err != nil { 21 | return nil, errors.Wrap(err, "NewMigrator") 22 | } 23 | 24 | return migrator, nil 25 | } 26 | 27 | func (migrator *Migrator) Up() error { 28 | if err := migrator.postgresMigrator.Up(); err != nil { 29 | if !errors.Is(err, migrate.ErrNoChange) { 30 | return errors.Wrap(err, "migrator.Up: failed to run migrations for Postgres DB") 31 | } 32 | } 33 | 34 | return nil 35 | } 36 | 37 | func (migrator *Migrator) WithLogger(logger migrate.Logger) *Migrator { 38 | migrator.postgresMigrator.Log = logger 39 | 40 | return migrator 41 | } 42 | 43 | func (migrator *Migrator) configure(postgresDBConn *sql.DB, migrationsPath string) error { 44 | config := &postgres.Config{MigrationsTable: "customer_migrations"} 45 | 46 | driver, err := postgres.WithInstance(postgresDBConn, config) 47 | if err != nil { 48 | return errors.Wrap(errors.Mark(err, shared.ErrTechnical), "failed to create Postgres driver for migrator") 49 | } 50 | 51 | sourceURL := fmt.Sprintf("file://%s", migrationsPath) 52 | realMigrator, err := migrate.NewWithDatabaseInstance(sourceURL, "postgres", driver) 53 | if err != nil { 54 | return errors.Wrap(errors.Mark(err, shared.ErrTechnical), "failed to create migrator instance") 55 | } 56 | 57 | migrator.postgresMigrator = realMigrator 58 | 59 | return nil 60 | } 61 | -------------------------------------------------------------------------------- /src/customeraccounts/infrastructure/adapter/postgres/database/migrations/1_create_eventstore.up.sql: -------------------------------------------------------------------------------- 1 | BEGIN; 2 | 3 | CREATE TABLE IF NOT EXISTS eventstore 4 | ( 5 | id serial not null, 6 | stream_id varchar(255) not null, 7 | stream_version integer default 0 not null, 8 | event_name varchar(255) not null, 9 | payload jsonb default '{}'::jsonb not null, 10 | occurred_at timestamp with time zone not null 11 | ); 12 | 13 | CREATE UNIQUE INDEX IF NOT EXISTS id_unique 14 | on eventstore (id); 15 | 16 | CREATE UNIQUE INDEX IF NOT EXISTS stream_unique 17 | on eventstore (stream_id, stream_version); 18 | 19 | CREATE INDEX IF NOT EXISTS event_name_idx 20 | on eventstore (event_name); 21 | 22 | CREATE INDEX IF NOT EXISTS occurred_at_idx 23 | on eventstore (occurred_at); 24 | 25 | COMMIT; -------------------------------------------------------------------------------- /src/customeraccounts/infrastructure/adapter/postgres/database/migrations/2_create_unique_email_addresses.up.sql: -------------------------------------------------------------------------------- 1 | BEGIN; 2 | 3 | CREATE TABLE IF NOT EXISTS unique_email_addresses 4 | ( 5 | email_address VARCHAR(255) 6 | CONSTRAINT unique_email_addresses_pk 7 | PRIMARY KEY, 8 | customer_id VARCHAR(255) DEFAULT NULL NOT NULL 9 | ); 10 | 11 | CREATE INDEX IF NOT EXISTS email_addresses_customer_id_idx 12 | on unique_email_addresses (customer_id); 13 | 14 | COMMIT; -------------------------------------------------------------------------------- /src/customeraccounts/infrastructure/adapter/postgres/database/setup/init-db.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" <<-EOSQL 5 | CREATE USER $GOIDDD_USERNAME WITH PASSWORD '$GOIDDD_PASSWORD'; 6 | 7 | CREATE DATABASE $GOIDDD_LOCAL_DATABASE; 8 | GRANT ALL privileges ON DATABASE $GOIDDD_LOCAL_DATABASE to $GOIDDD_USERNAME; 9 | 10 | CREATE DATABASE $GOIDDD_TEST_DATABASE; 11 | GRANT ALL privileges ON DATABASE $GOIDDD_TEST_DATABASE to $GOIDDD_USERNAME; 12 | EOSQL 13 | -------------------------------------------------------------------------------- /src/customeraccounts/infrastructure/adapter/rest/CustomHTTPError.go: -------------------------------------------------------------------------------- 1 | package customerrest 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "net/http" 7 | 8 | "github.com/grpc-ecosystem/grpc-gateway/runtime" 9 | "google.golang.org/grpc/status" 10 | ) 11 | 12 | type errorBody struct { 13 | Err string `json:"error,omitempty"` 14 | } 15 | 16 | func CustomHTTPError( 17 | _ context.Context, 18 | _ *runtime.ServeMux, 19 | marshaler runtime.Marshaler, 20 | w http.ResponseWriter, 21 | _ *http.Request, 22 | err error, 23 | ) { 24 | 25 | const fallback = `{"error": "failed to marshal error message"}` 26 | 27 | w.Header().Set("Content-type", marshaler.ContentType()) 28 | w.WriteHeader(runtime.HTTPStatusFromCode(status.Code(err))) 29 | 30 | jErr := json.NewEncoder(w).Encode( 31 | errorBody{ 32 | Err: status.Convert(err).Message(), 33 | }, 34 | ) 35 | 36 | if jErr != nil { 37 | _, _ = w.Write([]byte(fallback)) // useless to handle an error happening while writing a fallback error 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/customeraccounts/infrastructure/adapter/rest/customer.swagger.json: -------------------------------------------------------------------------------- 1 | { 2 | "swagger": "2.0", 3 | "info": { 4 | "title": "customer.proto", 5 | "version": "version not set" 6 | }, 7 | "consumes": [ 8 | "application/json" 9 | ], 10 | "produces": [ 11 | "application/json" 12 | ], 13 | "paths": { 14 | "/v1/customer": { 15 | "post": { 16 | "operationId": "Register", 17 | "responses": { 18 | "200": { 19 | "description": "A successful response.", 20 | "schema": { 21 | "$ref": "#/definitions/customergrpcprotoRegisterResponse" 22 | } 23 | }, 24 | "default": { 25 | "description": "An unexpected error response", 26 | "schema": { 27 | "$ref": "#/definitions/runtimeError" 28 | } 29 | } 30 | }, 31 | "parameters": [ 32 | { 33 | "name": "body", 34 | "in": "body", 35 | "required": true, 36 | "schema": { 37 | "$ref": "#/definitions/customergrpcprotoRegisterRequest" 38 | } 39 | } 40 | ], 41 | "tags": [ 42 | "Customer" 43 | ] 44 | } 45 | }, 46 | "/v1/customer/{id}": { 47 | "get": { 48 | "operationId": "RetrieveView", 49 | "responses": { 50 | "200": { 51 | "description": "A successful response.", 52 | "schema": { 53 | "$ref": "#/definitions/customergrpcprotoRetrieveViewResponse" 54 | } 55 | }, 56 | "default": { 57 | "description": "An unexpected error response", 58 | "schema": { 59 | "$ref": "#/definitions/runtimeError" 60 | } 61 | } 62 | }, 63 | "parameters": [ 64 | { 65 | "name": "id", 66 | "in": "path", 67 | "required": true, 68 | "type": "string" 69 | } 70 | ], 71 | "tags": [ 72 | "Customer" 73 | ] 74 | }, 75 | "delete": { 76 | "operationId": "Delete", 77 | "responses": { 78 | "200": { 79 | "description": "A successful response.", 80 | "schema": { 81 | "properties": {} 82 | } 83 | }, 84 | "default": { 85 | "description": "An unexpected error response", 86 | "schema": { 87 | "$ref": "#/definitions/runtimeError" 88 | } 89 | } 90 | }, 91 | "parameters": [ 92 | { 93 | "name": "id", 94 | "in": "path", 95 | "required": true, 96 | "type": "string" 97 | } 98 | ], 99 | "tags": [ 100 | "Customer" 101 | ] 102 | } 103 | }, 104 | "/v1/customer/{id}/emailaddress": { 105 | "put": { 106 | "operationId": "ChangeEmailAddress", 107 | "responses": { 108 | "200": { 109 | "description": "A successful response.", 110 | "schema": { 111 | "properties": {} 112 | } 113 | }, 114 | "default": { 115 | "description": "An unexpected error response", 116 | "schema": { 117 | "$ref": "#/definitions/runtimeError" 118 | } 119 | } 120 | }, 121 | "parameters": [ 122 | { 123 | "name": "id", 124 | "in": "path", 125 | "required": true, 126 | "type": "string" 127 | }, 128 | { 129 | "name": "body", 130 | "in": "body", 131 | "required": true, 132 | "schema": { 133 | "$ref": "#/definitions/customergrpcprotoChangeEmailAddressRequest" 134 | } 135 | } 136 | ], 137 | "tags": [ 138 | "Customer" 139 | ] 140 | } 141 | }, 142 | "/v1/customer/{id}/emailaddress/confirm": { 143 | "put": { 144 | "operationId": "ConfirmEmailAddress", 145 | "responses": { 146 | "200": { 147 | "description": "A successful response.", 148 | "schema": { 149 | "properties": {} 150 | } 151 | }, 152 | "default": { 153 | "description": "An unexpected error response", 154 | "schema": { 155 | "$ref": "#/definitions/runtimeError" 156 | } 157 | } 158 | }, 159 | "parameters": [ 160 | { 161 | "name": "id", 162 | "in": "path", 163 | "required": true, 164 | "type": "string" 165 | }, 166 | { 167 | "name": "body", 168 | "in": "body", 169 | "required": true, 170 | "schema": { 171 | "$ref": "#/definitions/customergrpcprotoConfirmEmailAddressRequest" 172 | } 173 | } 174 | ], 175 | "tags": [ 176 | "Customer" 177 | ] 178 | } 179 | }, 180 | "/v1/customer/{id}/name": { 181 | "put": { 182 | "operationId": "ChangeName", 183 | "responses": { 184 | "200": { 185 | "description": "A successful response.", 186 | "schema": { 187 | "properties": {} 188 | } 189 | }, 190 | "default": { 191 | "description": "An unexpected error response", 192 | "schema": { 193 | "$ref": "#/definitions/runtimeError" 194 | } 195 | } 196 | }, 197 | "parameters": [ 198 | { 199 | "name": "id", 200 | "in": "path", 201 | "required": true, 202 | "type": "string" 203 | }, 204 | { 205 | "name": "body", 206 | "in": "body", 207 | "required": true, 208 | "schema": { 209 | "$ref": "#/definitions/customergrpcprotoChangeNameRequest" 210 | } 211 | } 212 | ], 213 | "tags": [ 214 | "Customer" 215 | ] 216 | } 217 | } 218 | }, 219 | "definitions": { 220 | "customergrpcprotoChangeEmailAddressRequest": { 221 | "type": "object", 222 | "properties": { 223 | "id": { 224 | "type": "string" 225 | }, 226 | "emailAddress": { 227 | "type": "string" 228 | } 229 | } 230 | }, 231 | "customergrpcprotoChangeNameRequest": { 232 | "type": "object", 233 | "properties": { 234 | "id": { 235 | "type": "string" 236 | }, 237 | "givenName": { 238 | "type": "string" 239 | }, 240 | "familyName": { 241 | "type": "string" 242 | } 243 | } 244 | }, 245 | "customergrpcprotoConfirmEmailAddressRequest": { 246 | "type": "object", 247 | "properties": { 248 | "id": { 249 | "type": "string" 250 | }, 251 | "confirmationHash": { 252 | "type": "string" 253 | } 254 | } 255 | }, 256 | "customergrpcprotoRegisterRequest": { 257 | "type": "object", 258 | "properties": { 259 | "emailAddress": { 260 | "type": "string" 261 | }, 262 | "givenName": { 263 | "type": "string" 264 | }, 265 | "familyName": { 266 | "type": "string" 267 | } 268 | } 269 | }, 270 | "customergrpcprotoRegisterResponse": { 271 | "type": "object", 272 | "properties": { 273 | "id": { 274 | "type": "string" 275 | } 276 | } 277 | }, 278 | "customergrpcprotoRetrieveViewResponse": { 279 | "type": "object", 280 | "properties": { 281 | "emailAddress": { 282 | "type": "string" 283 | }, 284 | "isEmailAddressConfirmed": { 285 | "type": "boolean", 286 | "format": "boolean" 287 | }, 288 | "givenName": { 289 | "type": "string" 290 | }, 291 | "familyName": { 292 | "type": "string" 293 | }, 294 | "version": { 295 | "type": "string", 296 | "format": "uint64" 297 | } 298 | } 299 | }, 300 | "protobufAny": { 301 | "type": "object", 302 | "properties": { 303 | "type_url": { 304 | "type": "string" 305 | }, 306 | "value": { 307 | "type": "string", 308 | "format": "byte" 309 | } 310 | } 311 | }, 312 | "runtimeError": { 313 | "type": "object", 314 | "properties": { 315 | "error": { 316 | "type": "string" 317 | }, 318 | "code": { 319 | "type": "integer", 320 | "format": "int32" 321 | }, 322 | "message": { 323 | "type": "string" 324 | }, 325 | "details": { 326 | "type": "array", 327 | "items": { 328 | "$ref": "#/definitions/protobufAny" 329 | } 330 | } 331 | } 332 | } 333 | } 334 | } 335 | -------------------------------------------------------------------------------- /src/customeraccounts/infrastructure/serialization/CustomerEventJSONMapping.go: -------------------------------------------------------------------------------- 1 | package serialization 2 | 3 | import "github.com/AntonStoeckl/go-iddd/src/shared/es" 4 | 5 | type CustomerRegisteredForJSON struct { 6 | CustomerID string `json:"customerID"` 7 | EmailAddress string `json:"emailAddress"` 8 | ConfirmationHash string `json:"confirmationHash"` 9 | PersonGivenName string `json:"personGivenName"` 10 | PersonFamilyName string `json:"personFamilyName"` 11 | Meta es.EventMetaForJSON `json:"meta"` 12 | } 13 | 14 | type CustomerEmailAddressConfirmedForJSON struct { 15 | CustomerID string `json:"customerID"` 16 | EmailAddress string `json:"emailAddress"` 17 | Meta es.EventMetaForJSON `json:"meta"` 18 | } 19 | 20 | type CustomerEmailAddressConfirmationFailedForJSON struct { 21 | CustomerID string `json:"customerID"` 22 | ConfirmationHash string `json:"confirmationHash"` 23 | Reason string `json:"reason"` 24 | Meta es.EventMetaForJSON `json:"meta"` 25 | } 26 | 27 | type CustomerEmailAddressChangedForJSON struct { 28 | CustomerID string `json:"customerID"` 29 | EmailAddress string `json:"emailAddress"` 30 | ConfirmationHash string `json:"confirmationHash"` 31 | Meta es.EventMetaForJSON `json:"meta"` 32 | } 33 | 34 | type CustomerNameChangedForJSON struct { 35 | CustomerID string `json:"customerID"` 36 | GivenName string `json:"givenName"` 37 | FamilyName string `json:"familyName"` 38 | Meta es.EventMetaForJSON `json:"meta"` 39 | } 40 | 41 | type CustomerDeletedForJSON struct { 42 | CustomerID string `json:"customerID"` 43 | Meta es.EventMetaForJSON `json:"meta"` 44 | } 45 | -------------------------------------------------------------------------------- /src/customeraccounts/infrastructure/serialization/MarshalAndUnmarshalCustomerEvents_test.go: -------------------------------------------------------------------------------- 1 | package serialization 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/AntonStoeckl/go-iddd/src/customeraccounts/hexagon/application/domain" 8 | "github.com/AntonStoeckl/go-iddd/src/customeraccounts/hexagon/application/domain/customer/value" 9 | "github.com/AntonStoeckl/go-iddd/src/shared" 10 | "github.com/AntonStoeckl/go-iddd/src/shared/es" 11 | "github.com/cockroachdb/errors" 12 | . "github.com/smartystreets/goconvey/convey" 13 | ) 14 | 15 | func TestMarshalAndUnmarshalCustomerEvents(t *testing.T) { 16 | customerID := value.GenerateCustomerID() 17 | emailAddressInput := "john@doe.com" 18 | confirmationHash := value.GenerateConfirmationHash(emailAddressInput) 19 | unconfirmedEmailAddress := value.RebuildUnconfirmedEmailAddress(emailAddressInput, confirmationHash.String()) 20 | confirmedEmailAddress := value.RebuildConfirmedEmailAddress(emailAddressInput) 21 | changedEmailAddressInput := "john.frank@doe.com" 22 | changedConfirmationHash := value.GenerateConfirmationHash(changedEmailAddressInput) 23 | changedEmailAddress := value.RebuildUnconfirmedEmailAddress(changedEmailAddressInput, changedConfirmationHash.String()) 24 | personName := value.RebuildPersonName("John", "Doe") 25 | newPersonName := value.RebuildPersonName("John Frank", "Doe") 26 | failureReason := "wrong confirmation hash supplied" 27 | causationID := es.GenerateMessageID() 28 | 29 | var myEvents []es.DomainEvent 30 | streamVersion := uint(1) 31 | 32 | myEvents = append( 33 | myEvents, 34 | domain.BuildCustomerRegistered(customerID, unconfirmedEmailAddress, personName, causationID, streamVersion), 35 | ) 36 | 37 | streamVersion++ 38 | 39 | myEvents = append( 40 | myEvents, 41 | domain.BuildCustomerEmailAddressConfirmed(customerID, confirmedEmailAddress, causationID, streamVersion), 42 | ) 43 | 44 | streamVersion++ 45 | 46 | myEvents = append( 47 | myEvents, 48 | domain.BuildCustomerEmailAddressChanged(customerID, changedEmailAddress, causationID, streamVersion), 49 | ) 50 | 51 | streamVersion++ 52 | 53 | myEvents = append( 54 | myEvents, 55 | domain.BuildCustomerNameChanged(customerID, newPersonName, causationID, streamVersion), 56 | ) 57 | 58 | streamVersion++ 59 | 60 | myEvents = append( 61 | myEvents, 62 | domain.BuildCustomerDeleted(customerID, causationID, streamVersion), 63 | ) 64 | 65 | for idx, event := range myEvents { 66 | originalEvent := event 67 | streamVersion = uint(idx + 1) 68 | eventName := originalEvent.Meta().EventName() 69 | 70 | Convey(fmt.Sprintf("When %s is marshaled and unmarshaled", eventName), t, func() { 71 | json, err := MarshalCustomerEvent(originalEvent) 72 | So(err, ShouldBeNil) 73 | 74 | unmarshaledEvent, err := UnmarshalCustomerEvent(originalEvent.Meta().EventName(), json, streamVersion) 75 | So(err, ShouldBeNil) 76 | 77 | Convey(fmt.Sprintf("Then the unmarshaled %s should resemble the original %s", eventName, eventName), func() { 78 | So(unmarshaledEvent, ShouldResemble, originalEvent) 79 | }) 80 | }) 81 | } 82 | 83 | // Special treatment for Failure events because the FailureReason() 84 | // is a pointer to an error which does not resemble properly (ShouldResemble uses reflect.DeepEqual) 85 | 86 | Convey("When CustomerEmailAddressConfirmationFailed is marshaled and unmarshaled", t, func() { 87 | originalEvent := domain.BuildCustomerEmailAddressConfirmationFailed( 88 | customerID, 89 | confirmationHash, 90 | errors.Mark(errors.New(failureReason), shared.ErrDomainConstraintsViolation), 91 | causationID, 92 | streamVersion, 93 | ) 94 | 95 | oEventName := originalEvent.Meta().EventName() 96 | 97 | json, err := MarshalCustomerEvent(originalEvent) 98 | So(err, ShouldBeNil) 99 | 100 | unmarshaledEvent, err := UnmarshalCustomerEvent(originalEvent.Meta().EventName(), json, streamVersion) 101 | So(err, ShouldBeNil) 102 | 103 | uEventName := unmarshaledEvent.Meta().EventName() 104 | 105 | Convey(fmt.Sprintf("Then the unmarshaled %s should resemble the original %s", oEventName, uEventName), func() { 106 | unmarshaledEvent, ok := unmarshaledEvent.(domain.CustomerEmailAddressConfirmationFailed) 107 | So(ok, ShouldBeTrue) 108 | So(unmarshaledEvent.CustomerID().Equals(originalEvent.CustomerID()), ShouldBeTrue) 109 | So(unmarshaledEvent.ConfirmationHash().Equals(originalEvent.ConfirmationHash()), ShouldBeTrue) 110 | assertEventMetaResembles(originalEvent, unmarshaledEvent) 111 | }) 112 | }) 113 | } 114 | 115 | func assertEventMetaResembles(originalEvent, unmarshaledEvent es.DomainEvent) { 116 | So(unmarshaledEvent.Meta().EventName(), ShouldEqual, originalEvent.Meta().EventName()) 117 | So(unmarshaledEvent.Meta().OccurredAt(), ShouldEqual, originalEvent.Meta().OccurredAt()) 118 | So(unmarshaledEvent.Meta().CausationID(), ShouldEqual, originalEvent.Meta().CausationID()) 119 | So(unmarshaledEvent.Meta().StreamVersion(), ShouldEqual, originalEvent.Meta().StreamVersion()) 120 | So(unmarshaledEvent.IsFailureEvent(), ShouldEqual, originalEvent.IsFailureEvent()) 121 | So(unmarshaledEvent.FailureReason(), ShouldBeError) 122 | So(unmarshaledEvent.FailureReason().Error(), ShouldEqual, originalEvent.FailureReason().Error()) 123 | So(errors.Is(originalEvent.FailureReason(), shared.ErrDomainConstraintsViolation), ShouldBeTrue) 124 | So(errors.Is(unmarshaledEvent.FailureReason(), shared.ErrDomainConstraintsViolation), ShouldBeTrue) 125 | } 126 | 127 | func TestMarshalCustomerEvent_WithUnknownEvent(t *testing.T) { 128 | Convey("When an unknown event is marshaled", t, func() { 129 | _, err := MarshalCustomerEvent(SomeEvent{}) 130 | 131 | Convey("Then it should fail", func() { 132 | So(errors.Is(err, shared.ErrMarshalingFailed), ShouldBeTrue) 133 | }) 134 | }) 135 | } 136 | 137 | func TestUnmarshalCustomerEvent_WithUnknownEvent(t *testing.T) { 138 | Convey("When an unknown event is unmarshaled", t, func() { 139 | _, err := UnmarshalCustomerEvent("unknown", []byte{}, 1) 140 | 141 | Convey("Then it should fail", func() { 142 | So(errors.Is(err, shared.ErrUnmarshalingFailed), ShouldBeTrue) 143 | }) 144 | }) 145 | } 146 | 147 | /***** a mock event to test marshaling unknown event *****/ 148 | 149 | type SomeEvent struct{} 150 | 151 | func (event SomeEvent) Meta() es.EventMeta { 152 | return es.RebuildEventMeta("SomeEvent", "never", "someID", "someID", 1) 153 | } 154 | 155 | func (event SomeEvent) IsFailureEvent() bool { 156 | return false 157 | } 158 | 159 | func (event SomeEvent) FailureReason() error { 160 | return nil 161 | } 162 | -------------------------------------------------------------------------------- /src/customeraccounts/infrastructure/serialization/MarshalCustomerEvent.go: -------------------------------------------------------------------------------- 1 | package serialization 2 | 3 | import ( 4 | "github.com/AntonStoeckl/go-iddd/src/customeraccounts/hexagon/application/domain" 5 | "github.com/AntonStoeckl/go-iddd/src/shared" 6 | "github.com/AntonStoeckl/go-iddd/src/shared/es" 7 | "github.com/cockroachdb/errors" 8 | jsoniter "github.com/json-iterator/go" 9 | ) 10 | 11 | // MarshalCustomerEvent marshals every known Customer event to json. 12 | // It intentionally ignores marshaling errors, because they can't happen with the data types we are using. 13 | // We have a rich test suite which would catch such issues. 14 | func MarshalCustomerEvent(event es.DomainEvent) ([]byte, error) { 15 | var err error 16 | var json []byte 17 | 18 | switch actualEvent := event.(type) { 19 | case domain.CustomerRegistered: 20 | json = marshalCustomerRegistered(actualEvent) 21 | case domain.CustomerEmailAddressConfirmed: 22 | json = marshalCustomerEmailAddressConfirmed(actualEvent) 23 | case domain.CustomerEmailAddressConfirmationFailed: 24 | json = marshalCustomerEmailAddressConfirmationFailed(actualEvent) 25 | case domain.CustomerEmailAddressChanged: 26 | json = marshalCustomerEmailAddressChanged(actualEvent) 27 | case domain.CustomerNameChanged: 28 | json = marshalCustomerNameChanged(actualEvent) 29 | case domain.CustomerDeleted: 30 | json = marshalCustomerDeleted(actualEvent) 31 | default: 32 | err = errors.Wrapf(errors.New("event is unknown"), "marshalCustomerEvent [%s] failed", event.Meta().EventName()) 33 | return nil, errors.Mark(err, shared.ErrMarshalingFailed) 34 | } 35 | 36 | return json, nil 37 | } 38 | 39 | func marshalCustomerRegistered(event domain.CustomerRegistered) []byte { 40 | 41 | data := CustomerRegisteredForJSON{ 42 | CustomerID: event.CustomerID().String(), 43 | EmailAddress: event.EmailAddress().String(), 44 | ConfirmationHash: event.EmailAddress().ConfirmationHash().String(), 45 | PersonGivenName: event.PersonName().GivenName(), 46 | PersonFamilyName: event.PersonName().FamilyName(), 47 | Meta: marshalEventMeta(event), 48 | } 49 | 50 | json, _ := jsoniter.ConfigFastest.Marshal(data) // err intentionally ignored - see top comment 51 | 52 | return json 53 | } 54 | 55 | func marshalCustomerEmailAddressConfirmed(event domain.CustomerEmailAddressConfirmed) []byte { 56 | data := CustomerEmailAddressConfirmedForJSON{ 57 | CustomerID: event.CustomerID().String(), 58 | EmailAddress: event.EmailAddress().String(), 59 | Meta: marshalEventMeta(event), 60 | } 61 | 62 | json, _ := jsoniter.ConfigFastest.Marshal(data) // err intentionally ignored - see top comment 63 | 64 | return json 65 | } 66 | 67 | func marshalCustomerEmailAddressConfirmationFailed(event domain.CustomerEmailAddressConfirmationFailed) []byte { 68 | data := CustomerEmailAddressConfirmationFailedForJSON{ 69 | CustomerID: event.CustomerID().String(), 70 | ConfirmationHash: event.ConfirmationHash().String(), 71 | Reason: event.FailureReason().Error(), 72 | Meta: marshalEventMeta(event), 73 | } 74 | 75 | json, _ := jsoniter.ConfigFastest.Marshal(data) // err intentionally ignored - see top comment 76 | 77 | return json 78 | } 79 | 80 | func marshalCustomerEmailAddressChanged(event domain.CustomerEmailAddressChanged) []byte { 81 | data := CustomerEmailAddressChangedForJSON{ 82 | CustomerID: event.CustomerID().String(), 83 | EmailAddress: event.EmailAddress().String(), 84 | ConfirmationHash: event.EmailAddress().ConfirmationHash().String(), 85 | Meta: marshalEventMeta(event), 86 | } 87 | 88 | json, _ := jsoniter.ConfigFastest.Marshal(data) // err intentionally ignored - see top comment 89 | 90 | return json 91 | } 92 | 93 | func marshalCustomerNameChanged(event domain.CustomerNameChanged) []byte { 94 | data := CustomerNameChangedForJSON{ 95 | CustomerID: event.CustomerID().String(), 96 | GivenName: event.PersonName().GivenName(), 97 | FamilyName: event.PersonName().FamilyName(), 98 | Meta: marshalEventMeta(event), 99 | } 100 | 101 | json, _ := jsoniter.ConfigFastest.Marshal(data) // err intentionally ignored - see top comment 102 | 103 | return json 104 | } 105 | 106 | func marshalCustomerDeleted(event domain.CustomerDeleted) []byte { 107 | data := CustomerDeletedForJSON{ 108 | CustomerID: event.CustomerID().String(), 109 | Meta: marshalEventMeta(event), 110 | } 111 | 112 | json, _ := jsoniter.ConfigFastest.Marshal(data) // err intentionally ignored - see top comment 113 | 114 | return json 115 | } 116 | 117 | func marshalEventMeta(event es.DomainEvent) es.EventMetaForJSON { 118 | return es.EventMetaForJSON{ 119 | EventName: event.Meta().EventName(), 120 | OccurredAt: event.Meta().OccurredAt(), 121 | MessageID: event.Meta().MessageID(), 122 | CausationID: event.Meta().CausationID(), 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/customeraccounts/infrastructure/serialization/UnmarshalCustomerEvent.go: -------------------------------------------------------------------------------- 1 | package serialization 2 | 3 | import ( 4 | "github.com/AntonStoeckl/go-iddd/src/customeraccounts/hexagon/application/domain" 5 | "github.com/AntonStoeckl/go-iddd/src/shared" 6 | "github.com/AntonStoeckl/go-iddd/src/shared/es" 7 | "github.com/cockroachdb/errors" 8 | jsoniter "github.com/json-iterator/go" 9 | ) 10 | 11 | // UnmarshalCustomerEvent unmarshals every know Customer event. 12 | // It intentionally ignores unmarshaling errors, which could only happen if we would store invalid json to the EventStore. 13 | // We have a rich test suite which would catch such issues. 14 | func UnmarshalCustomerEvent( 15 | name string, 16 | payload []byte, 17 | streamVersion uint, 18 | ) (es.DomainEvent, error) { 19 | 20 | var event es.DomainEvent 21 | 22 | switch name { 23 | case "CustomerRegistered": 24 | event = unmarshalCustomerRegisteredFromJSON(payload, streamVersion) 25 | case "CustomerEmailAddressConfirmed": 26 | event = unmarshalCustomerEmailAddressConfirmedFromJSON(payload, streamVersion) 27 | case "CustomerEmailAddressConfirmationFailed": 28 | event = unmarshalCustomerEmailAddressConfirmationFailedFromJSON(payload, streamVersion) 29 | case "CustomerEmailAddressChanged": 30 | event = unmarshalCustomerEmailAddressChangedFromJSON(payload, streamVersion) 31 | case "CustomerNameChanged": 32 | event = unmarshalCustomerNameChangedFromJSON(payload, streamVersion) 33 | case "CustomerDeleted": 34 | event = unmarshalCustomerDeletedFromJSON(payload, streamVersion) 35 | default: 36 | err := errors.Wrapf(errors.New("event is unknown"), "unmarshalCustomerEvent [%s] failed", name) 37 | return nil, errors.Mark(err, shared.ErrUnmarshalingFailed) 38 | } 39 | 40 | return event, nil 41 | } 42 | 43 | func unmarshalCustomerRegisteredFromJSON( 44 | data []byte, 45 | streamVersion uint, 46 | ) domain.CustomerRegistered { 47 | 48 | unmarshaledData := &CustomerRegisteredForJSON{} 49 | 50 | _ = jsoniter.ConfigFastest.Unmarshal(data, unmarshaledData) // err intentionally ignored - see top comment 51 | 52 | event := domain.RebuildCustomerRegistered( 53 | unmarshaledData.CustomerID, 54 | unmarshaledData.EmailAddress, 55 | unmarshaledData.ConfirmationHash, 56 | unmarshaledData.PersonGivenName, 57 | unmarshaledData.PersonFamilyName, 58 | unmarshalEventMeta(unmarshaledData.Meta, streamVersion), 59 | ) 60 | 61 | return event 62 | } 63 | 64 | func unmarshalCustomerEmailAddressConfirmedFromJSON( 65 | data []byte, 66 | streamVersion uint, 67 | ) domain.CustomerEmailAddressConfirmed { 68 | 69 | unmarshaledData := &CustomerEmailAddressConfirmedForJSON{} 70 | 71 | _ = jsoniter.ConfigFastest.Unmarshal(data, unmarshaledData) // err intentionally ignored - see top comment 72 | 73 | event := domain.RebuildCustomerEmailAddressConfirmed( 74 | unmarshaledData.CustomerID, 75 | unmarshaledData.EmailAddress, 76 | unmarshalEventMeta(unmarshaledData.Meta, streamVersion), 77 | ) 78 | 79 | return event 80 | } 81 | 82 | func unmarshalCustomerEmailAddressConfirmationFailedFromJSON( 83 | data []byte, 84 | streamVersion uint, 85 | ) domain.CustomerEmailAddressConfirmationFailed { 86 | 87 | unmarshaledData := &CustomerEmailAddressConfirmationFailedForJSON{} 88 | 89 | _ = jsoniter.ConfigFastest.Unmarshal(data, unmarshaledData) // err intentionally ignored - see top comment 90 | 91 | event := domain.RebuildCustomerEmailAddressConfirmationFailed( 92 | unmarshaledData.CustomerID, 93 | unmarshaledData.ConfirmationHash, 94 | unmarshaledData.Reason, 95 | unmarshalEventMeta(unmarshaledData.Meta, streamVersion), 96 | ) 97 | 98 | return event 99 | } 100 | 101 | func unmarshalCustomerEmailAddressChangedFromJSON( 102 | data []byte, 103 | streamVersion uint, 104 | ) domain.CustomerEmailAddressChanged { 105 | 106 | unmarshaledData := &CustomerEmailAddressChangedForJSON{} 107 | 108 | _ = jsoniter.ConfigFastest.Unmarshal(data, unmarshaledData) // err intentionally ignored - see top comment 109 | 110 | event := domain.RebuildCustomerEmailAddressChanged( 111 | unmarshaledData.CustomerID, 112 | unmarshaledData.EmailAddress, 113 | unmarshaledData.ConfirmationHash, 114 | unmarshalEventMeta(unmarshaledData.Meta, streamVersion), 115 | ) 116 | 117 | return event 118 | } 119 | 120 | func unmarshalCustomerNameChangedFromJSON( 121 | data []byte, 122 | streamVersion uint, 123 | ) domain.CustomerNameChanged { 124 | 125 | unmarshaledData := &CustomerNameChangedForJSON{} 126 | 127 | _ = jsoniter.ConfigFastest.Unmarshal(data, unmarshaledData) // err intentionally ignored - see top comment 128 | 129 | event := domain.RebuildCustomerNameChanged( 130 | unmarshaledData.CustomerID, 131 | unmarshaledData.GivenName, 132 | unmarshaledData.FamilyName, 133 | unmarshalEventMeta(unmarshaledData.Meta, streamVersion), 134 | ) 135 | 136 | return event 137 | } 138 | 139 | func unmarshalCustomerDeletedFromJSON( 140 | data []byte, 141 | streamVersion uint, 142 | ) domain.CustomerDeleted { 143 | 144 | unmarshaledData := &CustomerDeletedForJSON{} 145 | 146 | _ = jsoniter.ConfigFastest.Unmarshal(data, unmarshaledData) // err intentionally ignored - see top comment 147 | 148 | event := domain.RebuildCustomerDeleted( 149 | unmarshaledData.CustomerID, 150 | unmarshalEventMeta(unmarshaledData.Meta, streamVersion), 151 | ) 152 | 153 | return event 154 | } 155 | 156 | func unmarshalEventMeta(meta es.EventMetaForJSON, streamVersion uint) es.EventMeta { 157 | return es.RebuildEventMeta( 158 | meta.EventName, 159 | meta.OccurredAt, 160 | meta.MessageID, 161 | meta.CausationID, 162 | streamVersion, 163 | ) 164 | } 165 | -------------------------------------------------------------------------------- /src/service/grpc/Config.go: -------------------------------------------------------------------------------- 1 | package grpc 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/AntonStoeckl/go-iddd/src/shared" 7 | "github.com/cockroachdb/errors" 8 | ) 9 | 10 | type Config struct { 11 | Postgres struct { 12 | DSN string 13 | MigrationsPathCustomer string 14 | } 15 | GRPC struct { 16 | HostAndPort string 17 | } 18 | } 19 | 20 | // ConfigExpectedEnvKeys - This is also used by Config_test.go to check that all keys exist in Env, 21 | // so always add new keys here! 22 | var ConfigExpectedEnvKeys = map[string]string{ 23 | "postgresDSN": "POSTGRES_DSN", 24 | "postgresMigrationsPathCustomer": "POSTGRES_MIGRATIONS_PATH_CUSTOMER", 25 | "grpcHostAndPort": "GRPC_HOST_AND_PORT", 26 | } 27 | 28 | func MustBuildConfigFromEnv(logger *shared.Logger) *Config { 29 | var err error 30 | conf := &Config{} 31 | msg := "mustBuildConfigFromEnv: %s - Hasta la vista, baby!" 32 | 33 | if conf.Postgres.DSN, err = conf.stringFromEnv(ConfigExpectedEnvKeys["postgresDSN"]); err != nil { 34 | logger.Panic().Msgf(msg, err) 35 | } 36 | 37 | if conf.Postgres.MigrationsPathCustomer, err = conf.stringFromEnv(ConfigExpectedEnvKeys["postgresMigrationsPathCustomer"]); err != nil { 38 | logger.Panic().Msgf(msg, err) 39 | } 40 | 41 | if conf.GRPC.HostAndPort, err = conf.stringFromEnv(ConfigExpectedEnvKeys["grpcHostAndPort"]); err != nil { 42 | logger.Panic().Msgf(msg, err) 43 | } 44 | 45 | return conf 46 | } 47 | 48 | func (conf Config) stringFromEnv(envKey string) (string, error) { 49 | envVal, ok := os.LookupEnv(envKey) 50 | if !ok { 51 | return "", errors.Mark(errors.Newf("config value [%s] missing in env", envKey), shared.ErrTechnical) 52 | } 53 | 54 | return envVal, nil 55 | } 56 | -------------------------------------------------------------------------------- /src/service/grpc/Config_test.go: -------------------------------------------------------------------------------- 1 | package grpc_test 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "testing" 7 | 8 | "github.com/AntonStoeckl/go-iddd/src/service/grpc" 9 | "github.com/AntonStoeckl/go-iddd/src/shared" 10 | . "github.com/smartystreets/goconvey/convey" 11 | ) 12 | 13 | func TestMustBuildConfigFromEnv(t *testing.T) { 14 | logger := shared.NewNilLogger() 15 | 16 | Convey("Given all values are set in Env", t, func() { 17 | Convey("When MustBuildConfigFromEnv is invoked", func() { 18 | config := grpc.MustBuildConfigFromEnv(logger) 19 | 20 | Convey("Then it should succeed", func() { 21 | wrapper := func() { grpc.MustBuildConfigFromEnv(logger) } 22 | So(wrapper, ShouldNotPanic) 23 | So(config, ShouldNotBeZeroValue) 24 | }) 25 | }) 26 | }) 27 | 28 | for _, envKey := range grpc.ConfigExpectedEnvKeys { 29 | currentEnvKey := envKey 30 | 31 | Convey(fmt.Sprintf("Given %s is missing in Env", envKey), t, func() { 32 | origEnvVal := os.Getenv(currentEnvKey) 33 | err := os.Unsetenv(currentEnvKey) 34 | So(err, ShouldBeNil) 35 | 36 | Convey("When MustBuildConfigFromEnv is invoked", func() { 37 | wrapper := func() { grpc.MustBuildConfigFromEnv(logger) } 38 | 39 | Convey("It should panic", func() { 40 | So(wrapper, ShouldPanic) 41 | }) 42 | }) 43 | 44 | err = os.Setenv(currentEnvKey, origEnvVal) 45 | So(err, ShouldBeNil) 46 | }) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/service/grpc/DIContainer.go: -------------------------------------------------------------------------------- 1 | package grpc 2 | 3 | import ( 4 | "database/sql" 5 | 6 | "github.com/AntonStoeckl/go-iddd/src/customeraccounts/hexagon/application" 7 | "github.com/AntonStoeckl/go-iddd/src/customeraccounts/hexagon/application/domain/customer" 8 | customergrpc "github.com/AntonStoeckl/go-iddd/src/customeraccounts/infrastructure/adapter/grpc" 9 | customergrpcproto "github.com/AntonStoeckl/go-iddd/src/customeraccounts/infrastructure/adapter/grpc/proto" 10 | "github.com/AntonStoeckl/go-iddd/src/customeraccounts/infrastructure/adapter/postgres" 11 | "github.com/AntonStoeckl/go-iddd/src/customeraccounts/infrastructure/serialization" 12 | "github.com/AntonStoeckl/go-iddd/src/shared" 13 | "github.com/AntonStoeckl/go-iddd/src/shared/es" 14 | "github.com/cockroachdb/errors" 15 | "google.golang.org/grpc" 16 | "google.golang.org/grpc/reflection" 17 | ) 18 | 19 | const ( 20 | eventStoreTableName = "eventstore" 21 | uniqueEmailAddressesTableName = "unique_email_addresses" 22 | ) 23 | 24 | type DIOption func(container *DIContainer) error 25 | 26 | func UsePostgresDBConn(dbConn *sql.DB) DIOption { 27 | return func(container *DIContainer) error { 28 | if dbConn == nil { 29 | return errors.New("pgDBConn must not be nil") 30 | } 31 | 32 | container.infra.pgDBConn = dbConn 33 | 34 | return nil 35 | } 36 | } 37 | 38 | func WithMarshalCustomerEvents(fn es.MarshalDomainEvent) DIOption { 39 | return func(container *DIContainer) error { 40 | container.dependency.marshalCustomerEvent = fn 41 | return nil 42 | } 43 | } 44 | 45 | func WithUnmarshalCustomerEvents(fn es.UnmarshalDomainEvent) DIOption { 46 | return func(container *DIContainer) error { 47 | container.dependency.unmarshalCustomerEvent = fn 48 | return nil 49 | } 50 | } 51 | 52 | func WithBuildUniqueEmailAddressAssertions(fn customer.ForBuildingUniqueEmailAddressAssertions) DIOption { 53 | return func(container *DIContainer) error { 54 | container.dependency.buildUniqueEmailAddressAssertions = fn 55 | return nil 56 | } 57 | } 58 | 59 | func ReplaceGRPCCustomerServer(server customergrpcproto.CustomerServer) DIOption { 60 | return func(container *DIContainer) error { 61 | if server == nil { 62 | return errors.New("grpcCustomerServer must not be nil") 63 | } 64 | 65 | container.service.grpcCustomerServer = server 66 | 67 | return nil 68 | } 69 | } 70 | 71 | type DIContainer struct { 72 | config *Config 73 | 74 | infra struct { 75 | pgDBConn *sql.DB 76 | } 77 | 78 | dependency struct { 79 | marshalCustomerEvent es.MarshalDomainEvent 80 | unmarshalCustomerEvent es.UnmarshalDomainEvent 81 | buildUniqueEmailAddressAssertions customer.ForBuildingUniqueEmailAddressAssertions 82 | } 83 | 84 | service struct { 85 | eventStore *es.EventStore 86 | customerEventStore *postgres.CustomerEventStore 87 | customerCommandHandler *application.CustomerCommandHandler 88 | customerQueryHandler *application.CustomerQueryHandler 89 | grpcCustomerServer customergrpcproto.CustomerServer 90 | grpcServer *grpc.Server 91 | } 92 | } 93 | 94 | func MustBuildDIContainer(config *Config, logger *shared.Logger, opts ...DIOption) *DIContainer { 95 | container := &DIContainer{} 96 | container.config = config 97 | 98 | /*** Define default dependencies ***/ 99 | container.dependency.marshalCustomerEvent = serialization.MarshalCustomerEvent 100 | container.dependency.unmarshalCustomerEvent = serialization.UnmarshalCustomerEvent 101 | container.dependency.buildUniqueEmailAddressAssertions = customer.BuildUniqueEmailAddressAssertions 102 | 103 | /*** Apply options for infra, dependencies, services ***/ 104 | for _, opt := range opts { 105 | if err := opt(container); err != nil { 106 | logger.Panic().Msgf("mustBuildDIContainer: %s", err) 107 | } 108 | } 109 | 110 | container.init() 111 | 112 | return container 113 | } 114 | 115 | func (container *DIContainer) init() { 116 | _ = container.getEventStore() 117 | _ = container.GetCustomerEventStore() 118 | _ = container.GetCustomerCommandHandler() 119 | _ = container.GetCustomerQueryHandler() 120 | _ = container.getGRPCCustomerServer() 121 | _ = container.GetGRPCServer() 122 | } 123 | 124 | func (container *DIContainer) GetPostgresDBConn() *sql.DB { 125 | return container.infra.pgDBConn 126 | } 127 | 128 | func (container *DIContainer) getEventStore() *es.EventStore { 129 | if container.service.eventStore == nil { 130 | container.service.eventStore = es.NewEventStore( 131 | eventStoreTableName, 132 | container.dependency.marshalCustomerEvent, 133 | container.dependency.unmarshalCustomerEvent, 134 | ) 135 | } 136 | 137 | return container.service.eventStore 138 | } 139 | 140 | func (container *DIContainer) GetCustomerEventStore() *postgres.CustomerEventStore { 141 | if container.service.customerEventStore == nil { 142 | uniqueCustomerEmailAddresses := postgres.NewUniqueCustomerEmailAddresses( 143 | uniqueEmailAddressesTableName, 144 | container.dependency.buildUniqueEmailAddressAssertions, 145 | ) 146 | 147 | container.service.customerEventStore = postgres.NewCustomerEventStore( 148 | container.infra.pgDBConn, 149 | container.getEventStore().RetrieveEventStream, 150 | container.getEventStore().AppendEventsToStream, 151 | container.getEventStore().PurgeEventStream, 152 | uniqueCustomerEmailAddresses.AssertUniqueEmailAddress, 153 | uniqueCustomerEmailAddresses.PurgeUniqueEmailAddress, 154 | ) 155 | } 156 | 157 | return container.service.customerEventStore 158 | } 159 | 160 | func (container *DIContainer) GetCustomerCommandHandler() *application.CustomerCommandHandler { 161 | if container.service.customerCommandHandler == nil { 162 | container.service.customerCommandHandler = application.NewCustomerCommandHandler( 163 | container.GetCustomerEventStore().RetrieveEventStream, 164 | container.GetCustomerEventStore().StartEventStream, 165 | container.GetCustomerEventStore().AppendToEventStream, 166 | ) 167 | } 168 | 169 | return container.service.customerCommandHandler 170 | } 171 | 172 | func (container *DIContainer) GetCustomerQueryHandler() *application.CustomerQueryHandler { 173 | if container.service.customerQueryHandler == nil { 174 | container.service.customerQueryHandler = application.NewCustomerQueryHandler( 175 | container.GetCustomerEventStore().RetrieveEventStream, 176 | ) 177 | } 178 | 179 | return container.service.customerQueryHandler 180 | } 181 | 182 | func (container *DIContainer) getGRPCCustomerServer() customergrpcproto.CustomerServer { 183 | if container.service.grpcCustomerServer == nil { 184 | container.service.grpcCustomerServer = customergrpc.NewCustomerServer( 185 | container.GetCustomerCommandHandler().RegisterCustomer, 186 | container.GetCustomerCommandHandler().ConfirmCustomerEmailAddress, 187 | container.GetCustomerCommandHandler().ChangeCustomerEmailAddress, 188 | container.GetCustomerCommandHandler().ChangeCustomerName, 189 | container.GetCustomerCommandHandler().DeleteCustomer, 190 | container.GetCustomerQueryHandler().CustomerViewByID, 191 | ) 192 | } 193 | 194 | return container.service.grpcCustomerServer 195 | } 196 | 197 | func (container *DIContainer) GetGRPCServer() *grpc.Server { 198 | if container.service.grpcServer == nil { 199 | container.service.grpcServer = grpc.NewServer() 200 | customergrpcproto.RegisterCustomerServer(container.service.grpcServer, container.getGRPCCustomerServer()) 201 | reflection.Register(container.service.grpcServer) 202 | } 203 | 204 | return container.service.grpcServer 205 | } 206 | -------------------------------------------------------------------------------- /src/service/grpc/DIContainer_test.go: -------------------------------------------------------------------------------- 1 | package grpc 2 | 3 | import ( 4 | "database/sql" 5 | "testing" 6 | 7 | "github.com/AntonStoeckl/go-iddd/src/customeraccounts/hexagon/application/domain/customer" 8 | "github.com/AntonStoeckl/go-iddd/src/shared" 9 | "github.com/AntonStoeckl/go-iddd/src/shared/es" 10 | . "github.com/smartystreets/goconvey/convey" 11 | ) 12 | 13 | func TestNewDIContainer(t *testing.T) { 14 | marshalDomainEvent := func(event es.DomainEvent) ([]byte, error) { 15 | return nil, nil 16 | } 17 | 18 | unmarshalDomainEvent := func(name string, payload []byte, streamVersion uint) (es.DomainEvent, error) { 19 | return nil, nil 20 | } 21 | 22 | buildUniqueEmailAddressAssertions := func(recordedEvents ...es.DomainEvent) customer.UniqueEmailAddressAssertions { 23 | return nil 24 | } 25 | 26 | Convey("When a DIContainer is created with valid input", t, func() { 27 | db, err := sql.Open("postgres", "postgresql://test:test@localhost:15432/test?sslmode=disable") 28 | So(err, ShouldBeNil) 29 | 30 | logger := shared.NewNilLogger() 31 | config := MustBuildConfigFromEnv(logger) 32 | 33 | callback := func() { 34 | _ = MustBuildDIContainer( 35 | config, 36 | logger, 37 | UsePostgresDBConn(db), 38 | WithMarshalCustomerEvents(marshalDomainEvent), 39 | WithUnmarshalCustomerEvents(unmarshalDomainEvent), 40 | WithBuildUniqueEmailAddressAssertions(buildUniqueEmailAddressAssertions), 41 | ) 42 | } 43 | 44 | Convey("Then it should succeed", func() { 45 | So(callback, ShouldNotPanic) 46 | }) 47 | }) 48 | 49 | Convey("When a DIContainer is created with a nil postgres DB connection", t, func() { 50 | var db *sql.DB 51 | 52 | logger := shared.NewNilLogger() 53 | config := MustBuildConfigFromEnv(logger) 54 | 55 | callback := func() { 56 | _ = MustBuildDIContainer( 57 | config, 58 | logger, 59 | UsePostgresDBConn(db), 60 | WithMarshalCustomerEvents(marshalDomainEvent), 61 | WithUnmarshalCustomerEvents(unmarshalDomainEvent), 62 | WithBuildUniqueEmailAddressAssertions(buildUniqueEmailAddressAssertions), 63 | ) 64 | } 65 | 66 | Convey("Then it should panic", func() { 67 | So(callback, ShouldPanic) 68 | }) 69 | }) 70 | } 71 | -------------------------------------------------------------------------------- /src/service/grpc/InitPostgresDB.go: -------------------------------------------------------------------------------- 1 | package grpc 2 | 3 | import ( 4 | "database/sql" 5 | 6 | "github.com/AntonStoeckl/go-iddd/src/customeraccounts/infrastructure/adapter/postgres/database" 7 | "github.com/AntonStoeckl/go-iddd/src/shared" 8 | ) 9 | 10 | func MustInitPostgresDB(config *Config, logger *shared.Logger) *sql.DB { 11 | var err error 12 | 13 | logger.Info().Msg("bootstrapPostgresDB: opening Postgres DB connection ...") 14 | 15 | postgresDBConn, err := sql.Open("postgres", config.Postgres.DSN) 16 | if err != nil { 17 | logger.Panic().Msgf("bootstrapPostgresDB: failed to open Postgres DB connection: %s", err) 18 | } 19 | 20 | err = postgresDBConn.Ping() 21 | if err != nil { 22 | logger.Panic().Msgf("bootstrapPostgresDB: failed to connect to Postgres DB: %s", err) 23 | } 24 | 25 | /***/ 26 | 27 | logger.Info().Msg("bootstrapPostgresDB: running DB migrations for customer ...") 28 | 29 | migratorCustomer, err := database.NewMigrator(postgresDBConn, config.Postgres.MigrationsPathCustomer) 30 | if err != nil { 31 | logger.Panic().Msgf("bootstrapPostgresDB: failed to create DB migrator for customer: %s", err) 32 | } 33 | 34 | migratorCustomer.WithLogger(logger) 35 | 36 | err = migratorCustomer.Up() 37 | if err != nil { 38 | logger.Panic().Msgf("bootstrapPostgresDB: failed to run DB migrations for customer: %s", err) 39 | } 40 | 41 | return postgresDBConn 42 | } 43 | -------------------------------------------------------------------------------- /src/service/grpc/Service.go: -------------------------------------------------------------------------------- 1 | package grpc 2 | 3 | import ( 4 | "net" 5 | "os" 6 | "os/signal" 7 | "syscall" 8 | 9 | "github.com/AntonStoeckl/go-iddd/src/shared" 10 | ) 11 | 12 | type Service struct { 13 | config *Config 14 | logger *shared.Logger 15 | diContainter *DIContainer 16 | exitFn func() 17 | } 18 | 19 | func InitService( 20 | config *Config, 21 | logger *shared.Logger, 22 | exitFn func(), 23 | diContainter *DIContainer, 24 | ) *Service { 25 | 26 | return &Service{ 27 | config: config, 28 | logger: logger, 29 | exitFn: exitFn, 30 | diContainter: diContainter, 31 | } 32 | } 33 | 34 | func (s *Service) StartGRPCServer() { 35 | s.logger.Info().Msg("configuring gRPC server ...") 36 | 37 | listener, err := net.Listen("tcp", s.config.GRPC.HostAndPort) 38 | if err != nil { 39 | s.logger.Error().Msgf("failed to listen: %v", err) 40 | s.shutdown() 41 | } 42 | 43 | s.logger.Info().Msgf("starting gRPC server listening at %s ...", s.config.GRPC.HostAndPort) 44 | 45 | grpcServer := s.diContainter.GetGRPCServer() 46 | if err := grpcServer.Serve(listener); err != nil { 47 | s.logger.Error().Msgf("gRPC server failed to serve: %s", err) 48 | s.shutdown() 49 | } 50 | } 51 | 52 | func (s *Service) WaitForStopSignal() { 53 | s.logger.Info().Msg("start waiting for stop signal ...") 54 | 55 | stopSignalChannel := make(chan os.Signal, 1) 56 | signal.Notify(stopSignalChannel, os.Interrupt, syscall.SIGTERM) 57 | 58 | sig := <-stopSignalChannel 59 | 60 | if _, ok := sig.(os.Signal); ok { 61 | s.logger.Info().Msgf("received '%s'", sig) 62 | close(stopSignalChannel) 63 | s.shutdown() 64 | } 65 | } 66 | 67 | func (s *Service) shutdown() { 68 | s.logger.Info().Msg("shutdown: stopping services ...") 69 | 70 | grpcServer := s.diContainter.GetGRPCServer() 71 | if grpcServer != nil { 72 | s.logger.Info().Msg("shutdown: stopping gRPC server gracefully ...") 73 | grpcServer.GracefulStop() 74 | } 75 | 76 | postgresDBConn := s.diContainter.GetPostgresDBConn() 77 | if postgresDBConn != nil { 78 | s.logger.Info().Msg("shutdown: closing Postgres DB connection ...") 79 | if err := postgresDBConn.Close(); err != nil { 80 | s.logger.Warn().Msgf("shutdown: failed to close the Postgres DB connection: %s", err) 81 | } 82 | } 83 | 84 | s.logger.Info().Msg("shutdown: all services stopped - Hasta la vista, baby!") 85 | 86 | s.exitFn() 87 | } 88 | -------------------------------------------------------------------------------- /src/service/grpc/Service_test.go: -------------------------------------------------------------------------------- 1 | package grpc_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "syscall" 7 | "testing" 8 | "time" 9 | 10 | "github.com/AntonStoeckl/go-iddd/src/customeraccounts/hexagon/application/domain/customer" 11 | "github.com/AntonStoeckl/go-iddd/src/customeraccounts/hexagon/application/domain/customer/value" 12 | customergrpc "github.com/AntonStoeckl/go-iddd/src/customeraccounts/infrastructure/adapter/grpc" 13 | customergrpcproto "github.com/AntonStoeckl/go-iddd/src/customeraccounts/infrastructure/adapter/grpc/proto" 14 | grpcService "github.com/AntonStoeckl/go-iddd/src/service/grpc" 15 | "github.com/AntonStoeckl/go-iddd/src/shared" 16 | . "github.com/smartystreets/goconvey/convey" 17 | "google.golang.org/grpc" 18 | "google.golang.org/grpc/codes" 19 | "google.golang.org/grpc/status" 20 | ) 21 | 22 | func TestStartGRPCServer(t *testing.T) { 23 | logger := shared.NewNilLogger() 24 | config := grpcService.MustBuildConfigFromEnv(logger) 25 | postgresDBConn := grpcService.MustInitPostgresDB(config, logger) 26 | diContainer := grpcService.MustBuildDIContainer( 27 | config, 28 | logger, 29 | grpcService.UsePostgresDBConn(postgresDBConn), 30 | grpcService.ReplaceGRPCCustomerServer(grpcCustomerServerStub()), 31 | ) 32 | 33 | exitWasCalled := false 34 | exitFn := func() { 35 | exitWasCalled = true 36 | } 37 | 38 | terminateDelay := time.Millisecond * 100 39 | 40 | s := grpcService.InitService(config, logger, exitFn, diContainer) 41 | 42 | Convey("Start the gRPC server as a goroutine", t, func() { 43 | go s.StartGRPCServer() 44 | 45 | Convey("gPRC server should handle requests", func() { 46 | client := customerGRPCClient(config) 47 | res, err := client.Register(context.Background(), &customergrpcproto.RegisterRequest{}) 48 | So(err, ShouldBeNil) 49 | So(res, ShouldNotBeNil) 50 | So(res.Id, ShouldNotBeEmpty) 51 | 52 | Convey(fmt.Sprintf("It should wait for stop signal (scheduled after %s)", terminateDelay), func() { 53 | start := time.Now() 54 | go func() { 55 | time.Sleep(terminateDelay) 56 | _ = syscall.Kill(syscall.Getpid(), syscall.SIGTERM) 57 | }() 58 | 59 | s.WaitForStopSignal() 60 | 61 | So(time.Now(), ShouldHappenOnOrAfter, start.Add(terminateDelay)) 62 | 63 | Convey("Stop signal should issue Shutdown", func() { 64 | Convey("Shutdown should stop gRPC server", func() { 65 | _, err = client.Register(context.Background(), &customergrpcproto.RegisterRequest{}) 66 | So(err, ShouldBeError) 67 | So(status.Code(err), ShouldResemble, codes.Unavailable) 68 | 69 | Convey("Shutdown should close PostgreSQL connection", func() { 70 | err := postgresDBConn.Ping() 71 | So(err, ShouldBeError) 72 | So(err.Error(), ShouldContainSubstring, "database is closed") 73 | 74 | Convey("Shutdown should call exit", func() { 75 | So(exitWasCalled, ShouldBeTrue) 76 | }) 77 | }) 78 | }) 79 | }) 80 | }) 81 | }) 82 | }) 83 | } 84 | 85 | /*** Helper functions ***/ 86 | 87 | func grpcCustomerServerStub() customergrpcproto.CustomerServer { 88 | customerServer := customergrpc.NewCustomerServer( 89 | func(customerIDValue value.CustomerID, emailAddress, givenName, familyName string) error { 90 | return nil 91 | }, 92 | func(customerID, confirmationHash string) error { 93 | return nil 94 | }, 95 | func(customerID, emailAddress string) error { 96 | return nil 97 | }, 98 | func(customerID, givenName, familyName string) error { 99 | return nil 100 | }, 101 | func(customerID string) error { 102 | return nil 103 | }, 104 | func(customerID string) (customer.View, error) { 105 | return customer.View{}, nil 106 | }, 107 | ) 108 | 109 | return customerServer 110 | } 111 | 112 | func customerGRPCClient(config *grpcService.Config) customergrpcproto.CustomerClient { 113 | grpcClientConn, _ := grpc.DialContext(context.Background(), config.GRPC.HostAndPort, grpc.WithInsecure(), grpc.WithBlock()) 114 | client := customergrpcproto.NewCustomerClient(grpcClientConn) 115 | 116 | return client 117 | } 118 | -------------------------------------------------------------------------------- /src/service/grpc/cmd/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/AntonStoeckl/go-iddd/src/service/grpc" 7 | "github.com/AntonStoeckl/go-iddd/src/shared" 8 | _ "github.com/golang-migrate/migrate/v4/source/file" 9 | ) 10 | 11 | func main() { 12 | stdLogger := shared.NewStandardLogger() 13 | config := grpc.MustBuildConfigFromEnv(stdLogger) 14 | exitFn := func() { os.Exit(1) } 15 | postgresDBConn := grpc.MustInitPostgresDB(config, stdLogger) 16 | diContainer := grpc.MustBuildDIContainer( 17 | config, 18 | stdLogger, 19 | grpc.UsePostgresDBConn(postgresDBConn), 20 | ) 21 | 22 | s := grpc.InitService(config, stdLogger, exitFn, diContainer) 23 | go s.StartGRPCServer() 24 | s.WaitForStopSignal() 25 | } 26 | -------------------------------------------------------------------------------- /src/service/rest/Config.go: -------------------------------------------------------------------------------- 1 | package rest 2 | 3 | import ( 4 | "os" 5 | "strconv" 6 | 7 | "github.com/AntonStoeckl/go-iddd/src/shared" 8 | "github.com/cockroachdb/errors" 9 | ) 10 | 11 | type Config struct { 12 | REST struct { 13 | HostAndPort string 14 | GRPCDialHostAndPort string 15 | GRPCDialTimeout int 16 | SwaggerFilePathCustomer string 17 | } 18 | } 19 | 20 | // ConfigExpectedEnvKeys - This is also used by Config_test.go to check that all keys exist in Env, 21 | // so always add new keys here! 22 | var ConfigExpectedEnvKeys = map[string]string{ 23 | "restHostAndPort": "REST_HOST_AND_PORT", 24 | "grpcDialHostAndPort": "GRPC_HOST_AND_PORT", 25 | "restGrpcDialTimeout": "REST_GRPC_DIAL_TIMEOUT", 26 | "swiggerFilePathCustomer": "SWAGGER_FILE_PATH_CUSTOMER", 27 | } 28 | 29 | func MustBuildConfigFromEnv(logger *shared.Logger) *Config { 30 | var err error 31 | conf := &Config{} 32 | msg := "mustBuildConfigFromEnv: %s - Hasta la vista, baby!" 33 | 34 | if conf.REST.HostAndPort, err = conf.stringFromEnv(ConfigExpectedEnvKeys["restHostAndPort"]); err != nil { 35 | logger.Panic().Msgf(msg, err) 36 | } 37 | 38 | if conf.REST.GRPCDialHostAndPort, err = conf.stringFromEnv(ConfigExpectedEnvKeys["grpcDialHostAndPort"]); err != nil { 39 | logger.Panic().Msgf(msg, err) 40 | } 41 | 42 | if conf.REST.GRPCDialTimeout, err = conf.intFromEnv(ConfigExpectedEnvKeys["restGrpcDialTimeout"]); err != nil { 43 | logger.Panic().Msgf(msg, err) 44 | } 45 | 46 | if conf.REST.SwaggerFilePathCustomer, err = conf.stringFromEnv(ConfigExpectedEnvKeys["swiggerFilePathCustomer"]); err != nil { 47 | logger.Panic().Msgf(msg, err) 48 | } 49 | 50 | return conf 51 | } 52 | 53 | func (conf Config) stringFromEnv(envKey string) (string, error) { 54 | envVal, ok := os.LookupEnv(envKey) 55 | if !ok { 56 | return "", errors.Mark(errors.Newf("config value [%s] missing in env", envKey), shared.ErrTechnical) 57 | } 58 | 59 | return envVal, nil 60 | } 61 | 62 | func (conf Config) intFromEnv(envKey string) (int, error) { 63 | envVal, ok := os.LookupEnv(envKey) 64 | if !ok { 65 | return 0, errors.Mark(errors.Newf("config value [%s] missing in env", envKey), shared.ErrTechnical) 66 | } 67 | 68 | intEnvVal, err := strconv.Atoi(envVal) 69 | if err != nil { 70 | return 0, errors.Mark(errors.Newf("config value [%s] is not convertable to integer", envKey), shared.ErrTechnical) 71 | } 72 | 73 | return intEnvVal, nil 74 | } 75 | -------------------------------------------------------------------------------- /src/service/rest/Config_test.go: -------------------------------------------------------------------------------- 1 | package rest_test 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "testing" 7 | 8 | "github.com/AntonStoeckl/go-iddd/src/service/rest" 9 | "github.com/AntonStoeckl/go-iddd/src/shared" 10 | . "github.com/smartystreets/goconvey/convey" 11 | ) 12 | 13 | func TestMustBuildConfigFromEnv(t *testing.T) { 14 | logger := shared.NewNilLogger() 15 | 16 | Convey("Given all values are set in Env", t, func() { 17 | Convey("When MustBuildConfigFromEnv is invoked", func() { 18 | config := rest.MustBuildConfigFromEnv(logger) 19 | 20 | Convey("Then it should succeed", func() { 21 | wrapper := func() { rest.MustBuildConfigFromEnv(logger) } 22 | So(wrapper, ShouldNotPanic) 23 | So(config, ShouldNotBeZeroValue) 24 | }) 25 | }) 26 | }) 27 | 28 | for _, envKey := range rest.ConfigExpectedEnvKeys { 29 | currentEnvKey := envKey 30 | 31 | Convey(fmt.Sprintf("Given %s is missing in Env", envKey), t, func() { 32 | origEnvVal := os.Getenv(currentEnvKey) 33 | err := os.Unsetenv(currentEnvKey) 34 | So(err, ShouldBeNil) 35 | 36 | Convey("When MustBuildConfigFromEnv is invoked", func() { 37 | wrapper := func() { rest.MustBuildConfigFromEnv(logger) } 38 | 39 | Convey("It should panic", func() { 40 | So(wrapper, ShouldPanic) 41 | }) 42 | }) 43 | 44 | err = os.Setenv(currentEnvKey, origEnvVal) 45 | So(err, ShouldBeNil) 46 | }) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/service/rest/Service.go: -------------------------------------------------------------------------------- 1 | package rest 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "os" 8 | "os/signal" 9 | "syscall" 10 | 11 | customergrpcproto "github.com/AntonStoeckl/go-iddd/src/customeraccounts/infrastructure/adapter/grpc/proto" 12 | customerrest "github.com/AntonStoeckl/go-iddd/src/customeraccounts/infrastructure/adapter/rest" 13 | customerrestproto "github.com/AntonStoeckl/go-iddd/src/customeraccounts/infrastructure/adapter/rest/proto" 14 | "github.com/AntonStoeckl/go-iddd/src/shared" 15 | "github.com/cockroachdb/errors" 16 | "github.com/grpc-ecosystem/grpc-gateway/runtime" 17 | "google.golang.org/grpc" 18 | ) 19 | 20 | type Service struct { 21 | config *Config 22 | logger *shared.Logger 23 | exitFn func() 24 | ctx context.Context 25 | cancelFn context.CancelFunc 26 | restServer *http.Server 27 | grpcClientConn *grpc.ClientConn 28 | } 29 | 30 | func MustDialGRPCContext( 31 | ctx context.Context, 32 | config *Config, 33 | logger *shared.Logger, 34 | cancelFn context.CancelFunc, 35 | ) *grpc.ClientConn { 36 | 37 | grpcClientConn, err := grpc.DialContext(ctx, config.REST.GRPCDialHostAndPort, grpc.WithInsecure(), grpc.WithBlock()) 38 | if err != nil { 39 | cancelFn() 40 | logger.Panic().Msgf("fail to dial gRPC service: %s", err) 41 | } 42 | 43 | return grpcClientConn 44 | } 45 | 46 | func InitService( 47 | ctx context.Context, 48 | cancelFn context.CancelFunc, 49 | config *Config, 50 | logger *shared.Logger, 51 | exitFn func(), 52 | grpcClientConn *grpc.ClientConn, 53 | 54 | ) *Service { 55 | 56 | s := &Service{ 57 | config: config, 58 | logger: logger, 59 | exitFn: exitFn, 60 | ctx: ctx, 61 | cancelFn: cancelFn, 62 | grpcClientConn: grpcClientConn, 63 | } 64 | 65 | s.buildRestServer() 66 | 67 | return s 68 | } 69 | 70 | func (s *Service) buildRestServer() { 71 | s.logger.Info().Msg("configuring REST server ...") 72 | 73 | client := customergrpcproto.NewCustomerClient(s.grpcClientConn) 74 | 75 | rmux := runtime.NewServeMux( 76 | runtime.WithProtoErrorHandler(customerrest.CustomHTTPError), 77 | ) 78 | 79 | if err := customerrestproto.RegisterCustomerHandlerClient(s.ctx, rmux, client); err != nil { 80 | s.logger.Error().Msgf("failed to register customerHandlerClient: %s", err) 81 | s.shutdown() 82 | } 83 | 84 | mux := http.NewServeMux() 85 | mux.Handle("/", rmux) 86 | 87 | // Serve the swagger file and swagger-ui (really?) 88 | mux.HandleFunc( 89 | "/v1/customer/swagger.json", 90 | func(w http.ResponseWriter, r *http.Request) { 91 | http.ServeFile(w, r, fmt.Sprintf("%s/customer.swagger.json", s.config.REST.SwaggerFilePathCustomer)) 92 | }, 93 | ) 94 | 95 | s.restServer = &http.Server{ 96 | Addr: s.config.REST.HostAndPort, 97 | Handler: mux, 98 | } 99 | } 100 | 101 | func (s *Service) StartRestServer() { 102 | hostAndPort := s.config.REST.HostAndPort 103 | s.logger.Info().Msgf("starting REST server listening at %s ...", hostAndPort) 104 | s.logger.Info().Msgf("will serve Swagger file at: http://%s/v1/customer/swagger.json", hostAndPort) 105 | 106 | if err := s.restServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { 107 | s.logger.Error().Msgf("REST server failed to listenAndServe: %s", err) 108 | s.shutdown() 109 | } 110 | } 111 | 112 | func (s *Service) WaitForStopSignal() { 113 | s.logger.Info().Msg("start waiting for stop signal ...") 114 | 115 | stopSignalChannel := make(chan os.Signal, 1) 116 | signal.Notify(stopSignalChannel, os.Interrupt, syscall.SIGTERM) 117 | 118 | sig := <-stopSignalChannel 119 | 120 | if _, ok := sig.(os.Signal); ok { 121 | s.logger.Info().Msgf("received '%s'", sig) 122 | close(stopSignalChannel) 123 | s.shutdown() 124 | } 125 | } 126 | 127 | func (s *Service) shutdown() { 128 | s.logger.Info().Msg("shutdown: stopping services ...") 129 | 130 | if s.cancelFn != nil { 131 | s.logger.Info().Msg("shutdown: canceling context ...") 132 | s.cancelFn() 133 | } 134 | 135 | if s.restServer != nil { 136 | s.logger.Info().Msg("shutdown: stopping REST server gracefully ...") 137 | if err := s.restServer.Shutdown(context.Background()); err != nil { 138 | s.logger.Warn().Msgf("shutdown: failed to stop the REST server: %s", err) 139 | } 140 | } 141 | 142 | if s.grpcClientConn != nil { 143 | s.logger.Info().Msg("shutdown: closing gRPC client connection ...") 144 | if err := s.grpcClientConn.Close(); err != nil { 145 | s.logger.Warn().Msgf("shutdown: failed to close the gRPC client connection: %s", err) 146 | } 147 | } 148 | 149 | s.logger.Info().Msg("shutdown: all services stopped - Hasta la vista, baby!") 150 | 151 | s.exitFn() 152 | } 153 | -------------------------------------------------------------------------------- /src/service/rest/Service_test.go: -------------------------------------------------------------------------------- 1 | package rest_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "syscall" 7 | "testing" 8 | "time" 9 | 10 | "github.com/AntonStoeckl/go-iddd/src/customeraccounts/hexagon/application/domain/customer" 11 | "github.com/AntonStoeckl/go-iddd/src/customeraccounts/hexagon/application/domain/customer/value" 12 | customergrpc "github.com/AntonStoeckl/go-iddd/src/customeraccounts/infrastructure/adapter/grpc" 13 | customergrpcproto "github.com/AntonStoeckl/go-iddd/src/customeraccounts/infrastructure/adapter/grpc/proto" 14 | grpcService "github.com/AntonStoeckl/go-iddd/src/service/grpc" 15 | restService "github.com/AntonStoeckl/go-iddd/src/service/rest" 16 | "github.com/AntonStoeckl/go-iddd/src/shared" 17 | "github.com/cockroachdb/errors" 18 | "github.com/go-resty/resty/v2" 19 | . "github.com/smartystreets/goconvey/convey" 20 | "google.golang.org/grpc/connectivity" 21 | ) 22 | 23 | func TestStartRestServer(t *testing.T) { 24 | mockedExistingCustomerID := "11111111" 25 | 26 | logger := shared.NewNilLogger() 27 | grpcConfig := grpcService.MustBuildConfigFromEnv(logger) 28 | restConfig := restService.MustBuildConfigFromEnv(logger) 29 | 30 | exitWasCalled := false 31 | exitFn := func() { 32 | exitWasCalled = true 33 | } 34 | ctx, cancelFn := context.WithTimeout(context.Background(), 1*time.Second) 35 | 36 | runGRPCServer(grpcConfig, logger, mockedExistingCustomerID) 37 | 38 | grpcClientConn := restService.MustDialGRPCContext(ctx, restConfig, logger, cancelFn) 39 | 40 | terminateDelay := time.Millisecond * 100 41 | 42 | s := restService.InitService(ctx, cancelFn, restConfig, logger, exitFn, grpcClientConn) 43 | 44 | Convey("Start the REST server as a goroutine", t, func() { 45 | go s.StartRestServer() 46 | 47 | Convey("REST server should handle successful requests serving a static file", func() { 48 | var err error 49 | var resp *resty.Response 50 | 51 | hostAndPort := restConfig.REST.HostAndPort 52 | 53 | client := resty.New() 54 | 55 | resp, err = client.R(). 56 | Get(fmt.Sprintf("http://%s/v1/customer/swagger.json", restConfig.REST.HostAndPort)) 57 | So(err, ShouldBeNil) 58 | So(resp.StatusCode(), ShouldEqual, 200) 59 | 60 | Convey("REST server should handle successful requests served via gRPC", func() { 61 | resp, err = client.R(). 62 | SetHeader("Content-Type", "application/json"). 63 | SetBody(`{"emailAddress": "anton+10@stoeckl.de", "familyName": "Stöckl", "givenName": "Anton"}`). 64 | Post(fmt.Sprintf("http://%s/v1/customer", hostAndPort)) 65 | 66 | So(err, ShouldBeNil) 67 | So(resp.StatusCode(), ShouldEqual, 200) 68 | 69 | resp, err = client.R(). 70 | Delete(fmt.Sprintf("http://%s/v1/customer/%s", hostAndPort, mockedExistingCustomerID)) 71 | So(err, ShouldBeNil) 72 | So(resp.StatusCode(), ShouldEqual, 200) 73 | 74 | Convey("REST server should handle failed requests served via gRPC", func() { 75 | notExistingCustomerID := "66666666" 76 | 77 | resp, _ := client.R(). 78 | Get(fmt.Sprintf("http://%s/v1/customer/%s", hostAndPort, notExistingCustomerID)) 79 | So(resp.StatusCode(), ShouldEqual, 404) 80 | 81 | Convey(fmt.Sprintf("It should wait for stop signal (scheduled after %s)", terminateDelay), func() { 82 | start := time.Now() 83 | go func() { 84 | time.Sleep(terminateDelay) 85 | _ = syscall.Kill(syscall.Getpid(), syscall.SIGTERM) 86 | }() 87 | 88 | s.WaitForStopSignal() 89 | 90 | So(time.Now(), ShouldNotHappenWithin, terminateDelay, start) 91 | 92 | Convey("Stop signal should issue Shutdown", func() { 93 | Convey("Shutdown should cancel the context", func() { 94 | So(errors.Is(ctx.Err(), context.Canceled), ShouldBeTrue) 95 | 96 | Convey("Shutdown should stop REST server", func() { 97 | resp, err := client.R(). 98 | Get(fmt.Sprintf("http://%s/v1/customer/1234", hostAndPort)) 99 | 100 | So(err, ShouldBeError) 101 | So(err.Error(), ShouldContainSubstring, "connection refused") 102 | So(resp.StatusCode(), ShouldBeZeroValue) 103 | 104 | Convey("Shutdown should close the grpc client connection", func() { 105 | So(grpcClientConn.GetState(), ShouldResemble, connectivity.Shutdown) 106 | 107 | Convey("Shutdown should call exit", func() { 108 | So(exitWasCalled, ShouldBeTrue) 109 | }) 110 | }) 111 | }) 112 | }) 113 | }) 114 | }) 115 | }) 116 | }) 117 | }) 118 | }) 119 | } 120 | 121 | /*** Helper functions ***/ 122 | 123 | func runGRPCServer(config *grpcService.Config, logger *shared.Logger, mockedExistingCustomerID string) { 124 | diContainer := grpcService.MustBuildDIContainer( 125 | config, 126 | logger, 127 | grpcService.ReplaceGRPCCustomerServer(grpcCustomerServerStub(mockedExistingCustomerID)), 128 | ) 129 | grpcSvc := grpcService.InitService(config, logger, func() {}, diContainer) 130 | go grpcSvc.StartGRPCServer() 131 | } 132 | 133 | func grpcCustomerServerStub(mockedExistingCustomerID string) customergrpcproto.CustomerServer { 134 | customerServer := customergrpc.NewCustomerServer( 135 | func(customerIDValue value.CustomerID, emailAddress, givenName, familyName string) error { 136 | return nil 137 | }, 138 | func(customerID, confirmationHash string) error { 139 | return nil 140 | }, 141 | func(customerID, emailAddress string) error { 142 | return nil 143 | }, 144 | func(customerID, givenName, familyName string) error { 145 | return nil 146 | }, 147 | func(customerID string) error { 148 | return nil 149 | }, 150 | func(customerID string) (customer.View, error) { 151 | switch customerID { 152 | case mockedExistingCustomerID: 153 | return customer.View{ID: customerID}, nil 154 | default: 155 | return customer.View{}, shared.ErrNotFound 156 | } 157 | }, 158 | ) 159 | 160 | return customerServer 161 | } 162 | -------------------------------------------------------------------------------- /src/service/rest/cmd/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "time" 7 | 8 | "github.com/AntonStoeckl/go-iddd/src/service/rest" 9 | "github.com/AntonStoeckl/go-iddd/src/shared" 10 | ) 11 | 12 | func main() { 13 | logger := shared.NewStandardLogger() 14 | config := rest.MustBuildConfigFromEnv(logger) 15 | exitFn := func() { os.Exit(1) } 16 | ctx, cancelFn := context.WithTimeout(context.Background(), time.Duration(config.REST.GRPCDialTimeout)*time.Second) 17 | grpcClientConn := rest.MustDialGRPCContext(ctx, config, logger, cancelFn) 18 | 19 | s := rest.InitService(ctx, cancelFn, config, logger, exitFn, grpcClientConn) 20 | go s.StartRestServer() 21 | s.WaitForStopSignal() 22 | } 23 | -------------------------------------------------------------------------------- /src/shared/Errors.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | import "github.com/cockroachdb/errors" 4 | 5 | var ( 6 | ErrInputIsInvalid = errors.New("input is invalid") 7 | ErrNotFound = errors.New("not found") 8 | ErrDuplicate = errors.New("duplicate") 9 | 10 | ErrDomainConstraintsViolation = errors.New("domain constraints violation") 11 | 12 | ErrMaxRetriesExceeded = errors.New("max retries exceeded") 13 | ErrConcurrencyConflict = errors.New("concurrency conflict") 14 | 15 | ErrMarshalingFailed = errors.New("marshaling failed") 16 | ErrUnmarshalingFailed = errors.New("unmarshaling failed") 17 | ErrTechnical = errors.New("technical") 18 | ) 19 | 20 | func MarkAndWrapError(original, markAs error, wrapWith string) error { 21 | return errors.Mark(errors.Wrap(original, wrapWith), markAs) 22 | } 23 | -------------------------------------------------------------------------------- /src/shared/Logger.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | import ( 4 | "os" 5 | "time" 6 | 7 | "github.com/rs/zerolog" 8 | ) 9 | 10 | type Logger struct { 11 | zerolog.Logger 12 | } 13 | 14 | func NewStandardLogger() *Logger { 15 | output := zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: time.RFC3339} 16 | logger := &Logger{zerolog.New(output).With().Caller().Timestamp().Logger()} 17 | 18 | return logger 19 | } 20 | 21 | func NewNilLogger() *Logger { 22 | logger := &Logger{zerolog.New(nil)} 23 | 24 | return logger 25 | } 26 | 27 | // Verbose() is needed to satisfy the Logger interface of github.com/golang-migrate/migrate/v4 28 | func (logger *Logger) Verbose() bool { 29 | return true 30 | } 31 | -------------------------------------------------------------------------------- /src/shared/Logger_test.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/smartystreets/goconvey/convey" 7 | ) 8 | 9 | func TestNewStandardLogger(t *testing.T) { 10 | Convey("When a new standard logger is created", t, func() { 11 | logger := NewStandardLogger() 12 | 13 | Convey("And it be configured to log verbose", func() { 14 | So(logger.Verbose(), ShouldBeTrue) 15 | }) 16 | }) 17 | 18 | Convey("When a new nil logger is created", t, func() { 19 | logger := NewNilLogger() 20 | 21 | Convey("It should be configured to log verbose", func() { 22 | So(logger.Verbose(), ShouldBeTrue) 23 | }) 24 | }) 25 | } 26 | -------------------------------------------------------------------------------- /src/shared/RetryOnConcurrencyConflict.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | import ( 4 | "github.com/cockroachdb/errors" 5 | ) 6 | 7 | func RetryOnConcurrencyConflict(originalFunc func() error, maxRetries uint8) error { 8 | var err error 9 | var retries uint8 10 | 11 | for retries = 0; retries < maxRetries; retries++ { 12 | // call next method in chain 13 | if err = originalFunc(); err == nil { 14 | return nil // no need to retry, call to originalFunc was successful 15 | } 16 | 17 | if !errors.Is(err, ErrConcurrencyConflict) { 18 | return err // don't retry for different errors 19 | } 20 | } 21 | 22 | return errors.Wrap(err, ErrMaxRetriesExceeded.Error()) 23 | } 24 | -------------------------------------------------------------------------------- /src/shared/RetryOnConcurrencyConflict_test.go: -------------------------------------------------------------------------------- 1 | package shared_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/AntonStoeckl/go-iddd/src/shared" 7 | "github.com/cockroachdb/errors" 8 | . "github.com/smartystreets/goconvey/convey" 9 | ) 10 | 11 | func TestRetryOnConcurrencyConflict(t *testing.T) { 12 | Convey("Setup", t, func() { 13 | retryFunc := shared.RetryOnConcurrencyConflict 14 | 15 | Convey("Assuming the original function returns a concurrency conflict error once", func() { 16 | var callCounter uint8 17 | howOftenToFail := uint8(1) 18 | originalFunc := func() error { 19 | callCounter++ 20 | 21 | if callCounter <= howOftenToFail { 22 | return errors.Mark(errors.New("mocked concurrency error"), shared.ErrConcurrencyConflict) 23 | } 24 | 25 | return nil 26 | } 27 | 28 | Convey("When RetryOnConcurrencyConflict is invoked with 3 maxRetries", func() { 29 | retries := uint8(3) 30 | 31 | Convey("Then it should succeed after retrying", func() { 32 | err := retryFunc(originalFunc, retries) 33 | So(err, ShouldBeNil) 34 | }) 35 | }) 36 | }) 37 | 38 | Convey("Assuming the original function returns a concurrency conflict error 3 times", func() { 39 | var callCounter uint8 40 | howOftenToFail := uint8(3) 41 | originalFunc := func() error { 42 | callCounter++ 43 | 44 | if callCounter <= howOftenToFail { 45 | return errors.Mark(errors.New("mocked concurrency error"), shared.ErrConcurrencyConflict) 46 | } 47 | 48 | return nil 49 | } 50 | 51 | Convey("When RetryOnConcurrencyConflict is invoked with 3 maxRetries", func() { 52 | retries := uint8(3) 53 | 54 | Convey("Then it should fail", func() { 55 | err := retryFunc(originalFunc, retries) 56 | So(err, ShouldBeError) 57 | So(errors.Is(err, shared.ErrConcurrencyConflict), ShouldBeTrue) 58 | }) 59 | }) 60 | }) 61 | 62 | Convey("Assuming the original function returns a different error", func() { 63 | var callCounter uint8 64 | howOftenToFail := uint8(1) 65 | originalFunc := func() error { 66 | callCounter++ 67 | 68 | if callCounter <= howOftenToFail { 69 | return errors.Mark(errors.New("mocked technical error"), shared.ErrTechnical) 70 | } 71 | 72 | return nil 73 | } 74 | 75 | Convey("When RetryOnConcurrencyConflict is invoked with 3 maxRetries", func() { 76 | retries := uint8(3) 77 | 78 | Convey("Then it should succeed after retrying", func() { 79 | err := retryFunc(originalFunc, retries) 80 | So(err, ShouldBeError) 81 | So(errors.Is(err, shared.ErrTechnical), ShouldBeTrue) 82 | }) 83 | }) 84 | }) 85 | }) 86 | } 87 | -------------------------------------------------------------------------------- /src/shared/es/DomainEvent.go: -------------------------------------------------------------------------------- 1 | package es 2 | 3 | type DomainEvent interface { 4 | Meta() EventMeta 5 | IsFailureEvent() bool 6 | FailureReason() error 7 | } 8 | -------------------------------------------------------------------------------- /src/shared/es/EventMeta.go: -------------------------------------------------------------------------------- 1 | package es 2 | 3 | import ( 4 | "reflect" 5 | "strings" 6 | "time" 7 | ) 8 | 9 | const ( 10 | metaTimestampFormat = time.RFC3339Nano 11 | ) 12 | 13 | type EventMeta struct { 14 | eventName string 15 | occurredAt string 16 | messageID string 17 | causationID string 18 | streamVersion uint 19 | } 20 | 21 | func BuildEventMeta( 22 | event DomainEvent, 23 | causationID MessageID, 24 | streamVersion uint, 25 | ) EventMeta { 26 | 27 | meta := EventMeta{ 28 | eventName: buildEventName(event), 29 | occurredAt: time.Now().Format(metaTimestampFormat), 30 | causationID: causationID.String(), 31 | messageID: GenerateMessageID().String(), 32 | streamVersion: streamVersion, 33 | } 34 | 35 | return meta 36 | } 37 | 38 | func buildEventName(event DomainEvent) string { 39 | eventType := reflect.TypeOf(event).String() 40 | eventTypeParts := strings.Split(eventType, ".") 41 | eventName := eventTypeParts[len(eventTypeParts)-1] 42 | 43 | return eventName 44 | } 45 | 46 | func RebuildEventMeta( 47 | eventName string, 48 | occurredAt string, 49 | messageID string, 50 | causationID string, 51 | streamVersion uint, 52 | ) EventMeta { 53 | 54 | return EventMeta{ 55 | eventName: eventName, 56 | occurredAt: occurredAt, 57 | messageID: messageID, 58 | causationID: causationID, 59 | streamVersion: streamVersion, 60 | } 61 | } 62 | 63 | func (eventMeta EventMeta) EventName() string { 64 | return eventMeta.eventName 65 | } 66 | 67 | func (eventMeta EventMeta) OccurredAt() string { 68 | return eventMeta.occurredAt 69 | } 70 | 71 | func (eventMeta EventMeta) MessageID() string { 72 | return eventMeta.messageID 73 | } 74 | 75 | func (eventMeta EventMeta) CausationID() string { 76 | return eventMeta.causationID 77 | } 78 | 79 | func (eventMeta EventMeta) StreamVersion() uint { 80 | return eventMeta.streamVersion 81 | } 82 | -------------------------------------------------------------------------------- /src/shared/es/EventMetaForJSON.go: -------------------------------------------------------------------------------- 1 | package es 2 | 3 | type EventMetaForJSON struct { 4 | EventName string `json:"eventName"` 5 | OccurredAt string `json:"occurredAt"` 6 | MessageID string `json:"messageID"` 7 | CausationID string `json:"causationID"` 8 | } 9 | -------------------------------------------------------------------------------- /src/shared/es/EventStore.go: -------------------------------------------------------------------------------- 1 | package es 2 | 3 | import ( 4 | "database/sql" 5 | "strings" 6 | 7 | "github.com/AntonStoeckl/go-iddd/src/shared" 8 | "github.com/cockroachdb/errors" 9 | "github.com/lib/pq" 10 | ) 11 | 12 | type EventStore struct { 13 | eventStoreTableName string 14 | marshalDomainEvent MarshalDomainEvent 15 | unmarshalDomainEvent UnmarshalDomainEvent 16 | } 17 | 18 | func NewEventStore( 19 | eventStoreTableName string, 20 | marshalDomainEvent MarshalDomainEvent, 21 | unmarshalDomainEvent UnmarshalDomainEvent, 22 | ) *EventStore { 23 | 24 | return &EventStore{ 25 | eventStoreTableName: eventStoreTableName, 26 | marshalDomainEvent: marshalDomainEvent, 27 | unmarshalDomainEvent: unmarshalDomainEvent, 28 | } 29 | } 30 | 31 | func (s *EventStore) RetrieveEventStream( 32 | streamID StreamID, 33 | fromVersion uint, 34 | maxEvents uint, 35 | db *sql.DB, 36 | ) (EventStream, error) { 37 | 38 | var err error 39 | wrapWithMsg := "retrieveEventStream" 40 | 41 | queryTemplate := `SELECT event_name, payload, stream_version FROM %name% 42 | WHERE stream_id = $1 AND stream_version >= $2 43 | ORDER BY stream_version ASC 44 | LIMIT $3` 45 | 46 | query := strings.Replace(queryTemplate, "%name%", s.eventStoreTableName, 1) 47 | 48 | eventRows, err := db.Query(query, streamID.String(), fromVersion, maxEvents) 49 | if err != nil { 50 | return nil, shared.MarkAndWrapError(err, shared.ErrTechnical, wrapWithMsg) 51 | } 52 | 53 | defer eventRows.Close() 54 | 55 | var eventStream EventStream 56 | var eventName string 57 | var payload string 58 | var streamVersion uint 59 | var domainEvent DomainEvent 60 | 61 | for eventRows.Next() { 62 | if eventRows.Err() != nil { 63 | return nil, shared.MarkAndWrapError(err, shared.ErrTechnical, wrapWithMsg) 64 | } 65 | 66 | if err = eventRows.Scan(&eventName, &payload, &streamVersion); err != nil { 67 | return nil, shared.MarkAndWrapError(err, shared.ErrTechnical, wrapWithMsg) 68 | } 69 | 70 | if domainEvent, err = s.unmarshalDomainEvent(eventName, []byte(payload), streamVersion); err != nil { 71 | return nil, shared.MarkAndWrapError(err, shared.ErrUnmarshalingFailed, wrapWithMsg) 72 | } 73 | 74 | eventStream = append(eventStream, domainEvent) 75 | } 76 | 77 | return eventStream, nil 78 | } 79 | 80 | func (s *EventStore) AppendEventsToStream( 81 | streamID StreamID, 82 | events []DomainEvent, 83 | tx *sql.Tx, 84 | ) error { 85 | 86 | var err error 87 | wrapWithMsg := "appendEventsToStream" 88 | 89 | queryTemplate := `INSERT INTO %name% (stream_id, stream_version, event_name, occurred_at, payload) 90 | VALUES ($1, $2, $3, $4, $5)` 91 | query := strings.Replace(queryTemplate, "%name%", s.eventStoreTableName, 1) 92 | 93 | for _, event := range events { 94 | var eventJSON []byte 95 | 96 | eventJSON, err = s.marshalDomainEvent(event) 97 | if err != nil { 98 | return shared.MarkAndWrapError(err, shared.ErrMarshalingFailed, wrapWithMsg) 99 | } 100 | 101 | _, err = tx.Exec( 102 | query, 103 | streamID.String(), 104 | event.Meta().StreamVersion(), 105 | event.Meta().EventName(), 106 | event.Meta().OccurredAt(), 107 | eventJSON, 108 | ) 109 | 110 | if err != nil { 111 | return errors.Wrap(s.mapEventStorePostgresErrors(err), wrapWithMsg) 112 | } 113 | } 114 | 115 | return nil 116 | } 117 | 118 | func (s *EventStore) PurgeEventStream( 119 | streamID StreamID, 120 | tx *sql.Tx, 121 | ) error { 122 | 123 | queryTemplate := `DELETE FROM %name% WHERE stream_id = $1` 124 | query := strings.Replace(queryTemplate, "%name%", s.eventStoreTableName, 1) 125 | 126 | if _, err := tx.Exec(query, streamID.String()); err != nil { 127 | return shared.MarkAndWrapError(err, shared.ErrTechnical, "purgeEventStream") 128 | } 129 | 130 | return nil 131 | } 132 | 133 | func (s *EventStore) mapEventStorePostgresErrors(err error) error { 134 | // nolint:errorlint // errors.As() suggested, but somehow cockroachdb/errors can't convert this properly 135 | if actualErr, ok := err.(*pq.Error); ok { 136 | if actualErr.Code == "23505" { 137 | return errors.Mark(err, shared.ErrConcurrencyConflict) 138 | } 139 | } 140 | 141 | return errors.Mark(err, shared.ErrTechnical) // some other DB error (Tx closed, wrong table, ...) 142 | } 143 | -------------------------------------------------------------------------------- /src/shared/es/EventStream.go: -------------------------------------------------------------------------------- 1 | package es 2 | 3 | type EventStream []DomainEvent 4 | -------------------------------------------------------------------------------- /src/shared/es/MarshalDomainEvent.go: -------------------------------------------------------------------------------- 1 | package es 2 | 3 | type MarshalDomainEvent func(event DomainEvent) ([]byte, error) 4 | -------------------------------------------------------------------------------- /src/shared/es/MessageID.go: -------------------------------------------------------------------------------- 1 | package es 2 | 3 | import ( 4 | "github.com/google/uuid" 5 | ) 6 | 7 | type MessageID string 8 | 9 | func GenerateMessageID() MessageID { 10 | return MessageID(uuid.New().String()) 11 | } 12 | 13 | func BuildMessageID(from MessageID) MessageID { 14 | return from 15 | } 16 | 17 | func RebuildMessageID(from string) MessageID { 18 | return MessageID(from) 19 | } 20 | 21 | func (messageID MessageID) String() string { 22 | return string(messageID) 23 | } 24 | 25 | func (messageID MessageID) Equals(other MessageID) bool { 26 | return messageID.String() == other.String() 27 | } 28 | -------------------------------------------------------------------------------- /src/shared/es/MessageID_test.go: -------------------------------------------------------------------------------- 1 | package es_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/google/uuid" 7 | 8 | "github.com/AntonStoeckl/go-iddd/src/shared/es" 9 | . "github.com/smartystreets/goconvey/convey" 10 | ) 11 | 12 | func TestMessageID_Generate(t *testing.T) { 13 | Convey("When a MessageID is generated", t, func() { 14 | messageID := es.GenerateMessageID() 15 | 16 | Convey("It should not be empty", func() { 17 | So(messageID, ShouldNotBeEmpty) 18 | }) 19 | }) 20 | } 21 | 22 | func TestMessageID_Build(t *testing.T) { 23 | Convey("When a MessageID is built from another MessageID", t, func() { 24 | otherMessageID := es.GenerateMessageID() 25 | messageID := es.BuildMessageID(otherMessageID) 26 | 27 | Convey("It should not be empty", func() { 28 | So(messageID, ShouldNotBeEmpty) 29 | 30 | Convey("And it should equal the input messageID", func() { 31 | So(messageID.Equals(otherMessageID), ShouldBeTrue) 32 | }) 33 | }) 34 | }) 35 | } 36 | 37 | func TestMessageID_Rebuild(t *testing.T) { 38 | Convey("When a MessageID is rebuilt from string", t, func() { 39 | messageIDString := uuid.New().String() 40 | messageID := es.RebuildMessageID(messageIDString) 41 | 42 | Convey("It should not be empty", func() { 43 | So(messageID, ShouldNotBeEmpty) 44 | 45 | Convey("And it should expose the expected value", func() { 46 | So(messageID.String(), ShouldEqual, messageIDString) 47 | }) 48 | }) 49 | }) 50 | } 51 | -------------------------------------------------------------------------------- /src/shared/es/RecordedEvents.go: -------------------------------------------------------------------------------- 1 | package es 2 | 3 | type RecordedEvents []DomainEvent 4 | -------------------------------------------------------------------------------- /src/shared/es/StreamID.go: -------------------------------------------------------------------------------- 1 | package es 2 | 3 | type StreamID string 4 | 5 | func BuildStreamID(from string) StreamID { 6 | if from == "" { 7 | panic("buildStreamID: empty input given") 8 | } 9 | 10 | return StreamID(from) 11 | } 12 | 13 | func (streamID StreamID) String() string { 14 | return string(streamID) 15 | } 16 | -------------------------------------------------------------------------------- /src/shared/es/StreamID_test.go: -------------------------------------------------------------------------------- 1 | package es_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/AntonStoeckl/go-iddd/src/shared/es" 7 | . "github.com/smartystreets/goconvey/convey" 8 | ) 9 | 10 | func TestStreamID_BuildStreamID(t *testing.T) { 11 | Convey("Given valid input", t, func() { 12 | streamIDInput := "customer-123" 13 | 14 | Convey("When a new StreamID is created", func() { 15 | streamID := es.BuildStreamID(streamIDInput) 16 | 17 | Convey("It should succeed", func() { 18 | So(streamID, ShouldNotBeNil) 19 | }) 20 | }) 21 | }) 22 | 23 | Convey("Given empty input", t, func() { 24 | streamIDInput := "" 25 | 26 | Convey("When a new StreamID is created", func() { 27 | newStreamIDWithEmptyInput := func() { 28 | es.BuildStreamID(streamIDInput) 29 | } 30 | 31 | Convey("It should fail with a panic", func() { 32 | So(newStreamIDWithEmptyInput, ShouldPanic) 33 | }) 34 | }) 35 | }) 36 | } 37 | 38 | func TestStreamID_String(t *testing.T) { 39 | Convey("Given a StreamID", t, func() { 40 | streamIDInput := "customer-123" 41 | streamID := es.BuildStreamID(streamIDInput) 42 | 43 | Convey("It should expose the expected value", func() { 44 | So(streamID.String(), ShouldEqual, streamIDInput) 45 | }) 46 | }) 47 | } 48 | -------------------------------------------------------------------------------- /src/shared/es/UnmarshalDomainEvent.go: -------------------------------------------------------------------------------- 1 | package es 2 | 3 | type UnmarshalDomainEvent func(name string, payload []byte, streamVersion uint) (DomainEvent, error) 4 | --------------------------------------------------------------------------------