├── .envrc ├── .github ├── actions │ ├── go-setup │ │ └── action.yaml │ ├── golangci-lint-setup │ │ └── action.yaml │ └── nix-setup │ │ └── action.yaml └── workflows │ ├── lint.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── .golangci.yaml ├── .goreleaser.yaml ├── .vscode ├── extensions.json └── settings.json ├── CHANGELOG.md ├── LICENSE ├── Makefile ├── README.md ├── aggregate ├── aggregate.go ├── aggregate_test.go ├── doc.go ├── event_sourced_repository.go ├── event_sourced_repository_test.go ├── repository.go ├── scenario.go └── scenario_test.go ├── command ├── command.go ├── command_test.go ├── doc.go ├── scenario.go └── scenario_test.go ├── event ├── doc.go ├── event.go ├── processor.go ├── store.go ├── store_inmemory.go └── store_tracking.go ├── firestore ├── doc.go ├── event_store.go └── event_store_test.go ├── flake.lock ├── flake.nix ├── go.mod ├── go.sum ├── internal └── user │ ├── aggregate.go │ ├── buf.gen.yaml │ ├── create_user.go │ ├── event.go │ ├── gen │ └── user │ │ └── v1 │ │ ├── event.pb.go │ │ └── user.pb.go │ ├── proto │ ├── buf.lock │ ├── buf.yaml │ └── user │ │ └── v1 │ │ ├── event.proto │ │ └── user.proto │ ├── serde.go │ ├── suite_aggregate_repository.go │ ├── suite_event_store.go │ └── user_by_email.go ├── message └── message.go ├── opentelemetry ├── config.go ├── doc.go ├── event_store.go └── repository.go ├── postgres ├── aggregate_repository.go ├── aggregate_repository_test.go ├── append_domain_events.go ├── doc.go ├── event_store.go ├── event_store_test.go ├── internal │ ├── container.go │ ├── internal.go │ └── transaction.go ├── migration.go ├── migrations │ ├── 1_events.down.sql │ ├── 1_events.up.sql │ ├── 2_aggregates.down.sql │ ├── 2_aggregates.up.sql │ ├── 3_fix_version_column.down.sql │ ├── 3_fix_version_column.up.sql │ ├── 4_remove_procedures.down.sql │ └── 4_remove_procedures.up.sql └── option.go ├── query ├── query.go ├── scenario.go └── scenario_test.go ├── renovate.json5 ├── resources └── logo.png ├── serde ├── bytes.go ├── chained.go ├── chained_test.go ├── doc.go ├── json.go ├── json_test.go ├── proto.go ├── protojson.go └── serde.go ├── sonar-project.properties └── version ├── check.go ├── doc.go └── version.go /.envrc: -------------------------------------------------------------------------------- 1 | use flake 2 | -------------------------------------------------------------------------------- /.github/actions/go-setup/action.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Setup Go 3 | description: | 4 | Sets up the Go toolchain cache for faster execution. 5 | runs: 6 | using: composite 7 | steps: 8 | - name: Setup cache 9 | uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 10 | with: 11 | path: | 12 | ~/.cache/go-build 13 | ~/go/pkg/mod 14 | key: ${{ runner.os }}-${{ github.workflow }}-golang-${{ hashFiles('**/go.sum') }} 15 | restore-keys: | 16 | ${{ runner.os }}-${{ github.workflow }}-golang- 17 | -------------------------------------------------------------------------------- /.github/actions/golangci-lint-setup/action.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Setup golangci-lint 3 | description: | 4 | Sets up golangci-lint cache and smooth buttery experience to make it go brrrr! 5 | runs: 6 | using: composite 7 | steps: 8 | - name: Setup Go 9 | uses: ./.github/actions/go-setup 10 | - name: Set cache directory in env 11 | shell: bash 12 | run: | 13 | echo "GOLANGCI_LINT_CACHE=$HOME/.cache/golangci-lint" >> $GITHUB_ENV 14 | - name: Setup cache 15 | uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 16 | with: 17 | path: ${{ env.GOLANGCI_LINT_CACHE }} 18 | key: ${{ runner.os }}-golangci-lint-cache-${{ hashFiles('**/go.sum') }} 19 | restore-keys: | 20 | ${{ runner.os }}-golangci-lint-cache- 21 | -------------------------------------------------------------------------------- /.github/actions/nix-setup/action.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Setup Nix and direnv 3 | description: | 4 | Sets up the Nix package manager and direnv to use the same packages 5 | vendored through Nix for local development. 6 | inputs: 7 | github_token: 8 | description: 'Github Access Token' 9 | required: true 10 | runs: 11 | using: composite 12 | steps: 13 | - name: Install Nix package manager 14 | uses: DeterminateSystems/nix-installer-action@e50d5f73bfe71c2dd0aa4218de8f4afa59f8f81d # v16 15 | with: 16 | extra-conf: | 17 | experimental-features = nix-command flakes 18 | - name: Set up Nix cache 19 | uses: DeterminateSystems/magic-nix-cache-action@6221693898146dc97e38ad0e013488a16477a4c4 # v9 20 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Lint 3 | 4 | concurrency: 5 | group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} 6 | cancel-in-progress: true 7 | 8 | on: # yamllint disable-line rule:truthy 9 | pull_request: 10 | branches: 11 | - main 12 | push: 13 | branches: 14 | - main 15 | 16 | permissions: read-all 17 | 18 | jobs: 19 | go: 20 | name: Go 21 | runs-on: ubuntu-latest 22 | permissions: 23 | contents: read 24 | pull-requests: write 25 | steps: 26 | - name: Checkout source code 27 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 28 | with: 29 | fetch-depth: 0 30 | - name: Set up Nix system 31 | uses: ./.github/actions/nix-setup 32 | with: 33 | github_token: ${{ secrets.GITHUB_TOKEN }} 34 | - name: Setup Go 35 | uses: ./.github/actions/go-setup # NOTE: used for caching only. 36 | - name: Set up golangci-lint 37 | uses: ./.github/actions/golangci-lint-setup 38 | - name: Run golangci-lint 39 | run: make go.lint 40 | shell: nix develop --quiet -c bash -e {0} 41 | 42 | super-linter: 43 | name: Super Linter 44 | runs-on: ubuntu-latest 45 | permissions: 46 | contents: read 47 | packages: read 48 | statuses: write 49 | steps: 50 | - name: Checkout source code 51 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 52 | with: 53 | # Full git history is needed to get a proper 54 | # list of changed files within `super-linter` 55 | fetch-depth: 0 56 | - name: Run super-linter 57 | uses: super-linter/super-linter@1fa6ba58a88783e9714725cf89ac26d53e80c148 # v6 58 | env: 59 | VALIDATE_ALL_CODEBASE: false 60 | DEFAULT_BRANCH: main 61 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 62 | # Linters are on the top-level of the repository. 63 | LINTER_RULES_PATH: ./ 64 | # Go is made out of copy-paste, forget this one. 65 | VALIDATE_JSCPD: false 66 | # NOTE: using Buf as linter, which is not supported by SuperLinter. 67 | VALIDATE_PROTOBUF: false 68 | # NOTE: super-linter has quite poor support for golangci-lint. 69 | # We use the official linter action for it instead. 70 | VALIDATE_GO: false 71 | VALIDATE_GO_MODULES: false 72 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Release 3 | 4 | on: workflow_dispatch 5 | 6 | concurrency: 7 | group: ${{ github.workflow }}-${{ github.ref }} 8 | cancel-in-progress: true 9 | 10 | permissions: 11 | contents: write 12 | pull-requests: write 13 | 14 | jobs: 15 | tag: 16 | name: Tag 17 | runs-on: ubuntu-latest 18 | permissions: 19 | # NOTE: necessary to apply the git tag. 20 | contents: write 21 | steps: 22 | - name: Checkout source code 23 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 24 | with: 25 | # Using this to pick up the latest tag. 26 | fetch-depth: 0 27 | - name: Get next version 28 | id: semver 29 | uses: ietf-tools/semver-action@778d1d5b7af80aa43f50104116b8363e7fc0d1ef # v1 30 | with: 31 | token: ${{ github.token }} 32 | branch: main 33 | minorList: 'major, breaking' 34 | patchList: 'feat, fix, bugfix, perf, refactor, test, tests, doc, docs' 35 | - name: Push new version tag 36 | uses: rickstaa/action-create-tag@a1c7777fcb2fee4f19b0f283ba888afa11678b72 # v1 37 | if: ${{ contains(github.ref, 'main') }} # only push tags if on main branch. 38 | with: 39 | tag: ${{ steps.semver.outputs.next }} 40 | tag_exists_error: false 41 | force_push_tag: true 42 | 43 | release: 44 | name: Release 45 | runs-on: ubuntu-latest 46 | needs: [tag] 47 | steps: 48 | - name: Checkout source code 49 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 50 | with: 51 | fetch-depth: 0 52 | - name: Set up Go 53 | uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5 54 | with: 55 | go-version: '1.24' 56 | - name: Run GoReleaser 57 | uses: goreleaser/goreleaser-action@9c156ee8a17a598857849441385a2041ef570552 # v6 58 | with: 59 | distribution: goreleaser 60 | version: latest 61 | args: release --clean 62 | env: 63 | GITHUB_TOKEN: ${{ github.token }} 64 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Test 3 | 4 | concurrency: 5 | group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} 6 | cancel-in-progress: true 7 | 8 | on: # yamllint disable-line rule:truthy 9 | pull_request: 10 | branches: 11 | - main 12 | push: 13 | branches: 14 | - main 15 | 16 | permissions: read-all 17 | 18 | jobs: 19 | go: 20 | name: Go 21 | runs-on: ubuntu-latest 22 | steps: 23 | - name: Checkout source code 24 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 25 | - name: Set up Nix system 26 | uses: ./.github/actions/nix-setup 27 | with: 28 | github_token: ${{ secrets.GITHUB_TOKEN }} 29 | - name: Setup Go 30 | uses: ./.github/actions/go-setup # NOTE: used for caching only. 31 | - name: Run 'make go.test' 32 | run: make go.test 33 | shell: nix develop --quiet -c bash -e {0} 34 | - name: Upload coverage report 35 | uses: codecov/codecov-action@0565863a31f2c772f9f0395002a31e3f06189574 # v5 36 | with: 37 | token: ${{ secrets.CODECOV_TOKEN }} 38 | files: ./coverage.txt 39 | verbose: true 40 | fail_ci_if_error: true 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # local development environment 2 | .direnv 3 | 4 | # go 5 | coverage.txt 6 | 7 | dist/ 8 | -------------------------------------------------------------------------------- /.golangci.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | version: "2" 3 | 4 | run: 5 | timeout: 5m 6 | 7 | linters: 8 | default: none 9 | enable: 10 | - bodyclose 11 | - copyloopvar 12 | - dogsled 13 | - errcheck 14 | - exhaustive 15 | - exhaustruct 16 | - funlen 17 | - gochecknoinits 18 | - gocognit 19 | - goconst 20 | - gocritic 21 | - gocyclo 22 | - godot 23 | - gomodguard 24 | - goprintffuncname 25 | - gosec 26 | - govet 27 | - ineffassign 28 | - lll 29 | - makezero 30 | - misspell 31 | - mnd 32 | - nakedret 33 | - nestif 34 | - nilerr 35 | - nolintlint 36 | - prealloc 37 | - revive 38 | - rowserrcheck 39 | - sqlclosecheck 40 | - staticcheck 41 | - tagliatelle 42 | - testpackage 43 | - unconvert 44 | - unparam 45 | - unused 46 | - wastedassign 47 | - whitespace 48 | - wsl 49 | settings: 50 | errcheck: 51 | check-type-assertions: true 52 | check-blank: true 53 | gocritic: 54 | disabled-checks: 55 | - dupImport 56 | enabled-tags: 57 | - diagnostic 58 | - experimental 59 | - opinionated 60 | - performance 61 | - style 62 | gocyclo: 63 | min-complexity: 20 64 | gomodguard: 65 | blocked: 66 | modules: 67 | - github.com/golang/protobuf: 68 | recommendations: 69 | - google.golang.org/protobuf 70 | lll: 71 | line-length: 160 72 | misspell: 73 | locale: US 74 | tagliatelle: 75 | case: 76 | rules: 77 | json: snake 78 | unparam: 79 | check-exported: true 80 | unused: 81 | exported-fields-are-used: false 82 | varnamelen: 83 | ignore-names: 84 | - err 85 | - tc 86 | - id 87 | exclusions: 88 | generated: lax 89 | rules: 90 | - linters: 91 | - funlen 92 | path: _test\.go 93 | paths: 94 | - third_party$ 95 | - builtin$ 96 | - examples$ 97 | 98 | issues: 99 | max-issues-per-linter: 0 100 | max-same-issues: 0 101 | 102 | formatters: 103 | enable: 104 | - gci 105 | - gofumpt 106 | - goimports 107 | settings: 108 | gci: 109 | sections: 110 | - standard 111 | - default 112 | - prefix(github.com/get-eventually/go-eventually) 113 | goimports: 114 | local-prefixes: 115 | - prefix(github.com/get-eventually/go-eventually) 116 | exclusions: 117 | generated: lax 118 | paths: 119 | - third_party$ 120 | - builtin$ 121 | - examples$ 122 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 1 3 | 4 | before: 5 | hooks: 6 | - go mod tidy 7 | 8 | builds: 9 | - env: 10 | - CGO_ENABLED=0 11 | goos: 12 | - linux 13 | - windows 14 | - darwin 15 | 16 | archives: 17 | - format: tar.gz 18 | # this name template makes the OS and Arch compatible with the results of `uname`. 19 | name_template: >- 20 | {{ .ProjectName }}_ 21 | {{- title .Os }}_ 22 | {{- if eq .Arch "amd64" }}x86_64 23 | {{- else if eq .Arch "386" }}i386 24 | {{- else }}{{ .Arch }}{{ end }} 25 | {{- if .Arm }}v{{ .Arm }}{{ end }} 26 | # use zip for windows archives 27 | format_overrides: 28 | - goos: windows 29 | format: zip 30 | 31 | changelog: 32 | sort: asc 33 | filters: 34 | exclude: 35 | - "^docs:" 36 | - "^test:" 37 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "bufbuild.vscode-buf", 4 | "golang.go", 5 | "zxh404.vscode-proto3", 6 | "esbenp.prettier-vscode", 7 | "mkhl.direnv", 8 | "jnoortheen.nix-ide", 9 | "redhat.vscode-yaml", 10 | "yzhang.markdown-all-in-one" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.tabSize": 2, 3 | "editor.formatOnSave": true, 4 | "files.insertFinalNewline": true, 5 | "files.trimTrailingWhitespace": true, 6 | "[json][jsonc]": { 7 | "editor.quickSuggestions": { 8 | "strings": true 9 | }, 10 | "editor.suggest.insertMode": "replace", 11 | "editor.defaultFormatter": "esbenp.prettier-vscode" 12 | }, 13 | "[markdown]": { 14 | "editor.defaultFormatter": "yzhang.markdown-all-in-one" 15 | }, 16 | "[makefile]": { 17 | "editor.insertSpaces": false, 18 | "editor.tabSize": 4 19 | }, 20 | "[proto][proto3]": { 21 | "editor.defaultFormatter": "bufbuild.vscode-buf" 22 | }, 23 | "[go]": { 24 | "editor.tabSize": 4 25 | }, 26 | // NOTE: these are loaded from the Nix shell through direnv. 27 | "go.alternateTools": { 28 | "go": "${env:GO_BIN_PATH}", 29 | "gopls": "${env:GOPLS_PATH}", 30 | "dlv": "${env:DLV_PATH}" 31 | }, 32 | "go.lintTool": "golangci-lint", 33 | "go.lintFlags": ["--fast"], 34 | "gopls": { 35 | "formatting.gofumpt": true, 36 | "ui.semanticTokens": true, 37 | "ui.codelenses": { 38 | "gc_details": true, 39 | "generate": true, 40 | "regenerate_cgo": true, 41 | "tidy": true, 42 | "upgrade_dependency": true, 43 | "vendor": true 44 | } 45 | }, 46 | "nix.serverPath": "nil", 47 | "nix.enableLanguageServer": true, 48 | "nix.serverSettings": { 49 | "nil": { 50 | "formatting": { 51 | "command": ["nixpkgs-fmt"] 52 | } 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/), and this 6 | project adheres to [Semantic Versioning](https://semver.org/). 7 | 8 | ## [Unreleased] 9 | 10 | ### Added 11 | 12 | - Usage of Go workspaces for local development. 13 | - New `core/message` package for defining messages. 14 | - `core/serde` package for serialization and deserialization of types. 15 | - `serdes` module using `core/serde` with some common protocol implementations: **Protobuf**, **ProtoJSON** and **JSON**. 16 | - `postgres.AggregateRepository` implementation to load/save Aggregates directly, and still saving recorded Domain Events in a separate table (`events`). 17 | - `oteleventually.InstrumentedRepository` provides an `aggregate.Repository` instrumentation. 18 | - New `scenario.AggregateRoot` API to BDD-like test scenario on an `aggregate.Root` instance. 19 | 20 | ### Changed 21 | 22 | - `aggregate` package uses Go generics for `aggregate.Repository` and `aggregate.Root` interfaces. 23 | - `eventually.Payload` is now `message.Message`. 24 | - `eventually.Message` is now `message.Envelope`. 25 | - `eventstore.Event` is now `event.Persisted`. 26 | - `eventstore.Store` is now `event.Store`. 27 | - `command.Command[T]` is now using `message.Message[T]`. 28 | - `command.Handler` is now generic over its `command.Command` input. 29 | - `scenario` package is now under `core/test/scenario`. 30 | - `scenario.CommandHandler` now uses generics for command and command handler assertion. 31 | - `postgres` module now uses `pgx` and `pgxpool` to handle connection with the PostgreSQL database, instead of `database/sql`. 32 | - `postgres.EventStore` uses `serde.Serializer` interface to serialize/deserialize Domain Events to `[]byte`. 33 | - `oteleventually.InstrumentedEventStore` is now adapted to the new `event.Store` interface. 34 | 35 | ### Removed 36 | 37 | - `SequenceNumber` from the `event.Persisted` struct (was previously `eventstore.Event`). 38 | - `eventstore.SequenceNumberGetter`, to follow the previous `SequenceNumber` removal. 39 | - `command.Dispatcher` interface, as implementing it with generics is currently not possible. 40 | 41 | ## [Pre-v0.2.0 unreleased changes] 42 | 43 | ### Changed 44 | 45 | - Add `logger.Logger` to `command.ErrorRecorder` to report errors when appending Command failures to the Event Store. 46 | - `command.ErrorRecorder` must be passed by reference to implement `command.Handler` interface now (size of the struct increased). 47 | 48 | ### Removed 49 | 50 | - Remove the `events` field from `oteleventually.InstrumentedEventStore` due to the potential size of the field and issues with exporting the trace (which wouldn't fit an UDP packet). 51 | - Remove the `event` field from `oteleventually.InstrumentedProjection`. 52 | 53 | ## [v0.1.0-alpha.4] 54 | 55 | ### Added 56 | 57 | - `X-Eventually-TraceId` and `X-Eventually-SpanId` metadata keys are recorded when using `oteleventually.InstrumentedEventStore.Append`. 58 | - Add `eventstore.ContextAware` and `eventstore.ContextMetadata` to set some Metadata in the context to be applied to all Domain Events appended to the Event Store. 59 | 60 | ### Changed 61 | 62 | - `postgres.Serializer` and `postgres.Deserializer` use `stream.ID` for the mapping function. 63 | - Update `go.opentelemetry.io/otel` to `v1.2.0` 64 | - Update `go.opentelemetry.io/otel/metric` to `v0.25.0` 65 | 66 | ## [v0.1.0-alpha.3] 67 | 68 | ### Added 69 | 70 | - Testcase for the Event Store testing suite to assert that `eventstore.Appender.Append` returns `eventstore.ErrConflict`. 71 | - `postgres.EventStore.Append` returns `eventstore.ErrConflict` in case of conflict now. 72 | 73 | ### Changed 74 | 75 | - Metric types in `oteleventually` have been adapted to the latest `v0.24.0` version. 76 | - `eventstore.ErrConflict` has been renamed to `eventstore.ConflictError`. 77 | 78 | ## [v0.1.0-alpha.2] 79 | 80 | ### Added 81 | 82 | - An option to override Event appending logic in Postgres EventStore implementation. 83 | - `postgres.Serde` interface to support more serialization formats. 84 | 85 | ### Changed 86 | 87 | - Existing `Event-Id` value in Event Metadata does not get overwritten in correlation.EventStoreWrapper. 88 | - `postgres.EventStore` now uses the `Serde` interface for serializing to and deserializing from byte array. 89 | - `postgres.Registry` is now called `postgres.JSONRegistry` and implements thenew `postgres.Serde` interface. 90 | - `CaptureErrors` in `command.ErrorRecorder` is now a function (`ShouldCaptureError`), to allow for a more flexible capture strategy. 91 | 92 | ## [v0.1.0-alpha.1] 93 | 94 | A lot of changes have happened here, a lot of different API design iterations and stuff. All of which, I diligently forgot to keep track of... 95 | 96 | Sorry :) 97 | 98 | 99 | 100 | [unreleased]: https://github.com/get-eventually/go-eventually/compare/eb0deb0..HEAD 101 | [pre-v0.2.0 unreleased changes]: https://github.com/get-eventually/go-eventually/compare/eb0deb0..HEAD 102 | [v0.1.0-alpha.4]: https://github.com/get-eventually/go-eventually/compare/v0.1.0-alpha.4..v0.1.0-alpha.3 103 | [v0.1.0-alpha.3]: https://github.com/get-eventually/go-eventually/compare/v0.1.0-alpha.2..v0.1.0-alpha.3 104 | [v0.1.0-alpha.2]: https://github.com/get-eventually/go-eventually/compare/v0.1.0-alpha.1..v0.1.0-alpha.2 105 | [v0.1.0-alpha.1]: https://github.com/get-eventually/go-eventually/compare/8bb9190..v0.1.0-alpha.1 106 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 eventually 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | MAKEFLAGS += -s --always-make -C 2 | SHELL := bash 3 | .SHELLFLAGS := -Eeuo pipefail -c 4 | 5 | ifndef DEBUG 6 | .SILENT: 7 | endif 8 | 9 | GOLANGCI_LINT_FLAGS ?= -v 10 | GO_TEST_FLAGS ?= -v -cover -covermode=atomic -coverprofile=coverage.txt -coverpkg=./... 11 | 12 | go.lint: 13 | golangci-lint run $(GOLANGCI_LINT_FLAGS) 14 | 15 | go.test: 16 | go test $(GO_TEST_FLAGS) ./... 17 | 18 | go.test.unit: 19 | go test -short $(GO_TEST_FLAGS) ./... 20 | 21 | go.mod.update: 22 | echo "==> update dependencies recursively" 23 | go get -u ./... 24 | echo "==> run 'go mod tidy'" 25 | go mod tidy 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 |
6 | Eventually 7 |
8 |
9 |
10 | 11 | Domain-driven Design, Event Sourcing and CQRS for Go 12 | 13 |
14 |
15 |
16 | 17 | 18 | Codecov 19 | 20 | 21 | 22 | Go Reference 24 | 25 | 26 | 27 | GitHub license 29 | 30 |
31 |
32 | 33 | > [!WARNING] 34 | > Though used in production environment, the library is still under active development. 35 | 36 | 37 | 38 | > [!NOTE] 39 | > Prior to `v1` release the following Semantic Versioning 40 | is being adopted: 41 | > 42 | > * Breaking changes are tagged with a new **minor** release, 43 | > * New features, patches and documentation are tagged with a new **patch** release. 44 | 45 | ## Overview 46 | 47 | `eventually` is a library providing abstractions and components to help you: 48 | 49 | * Build Domain-driven Design-oriented code, (Domain Events, Aggregate Root, etc.) 50 | 51 | * Reduce complexity of your code by providing ready-to-use components, such as PostgreSQL repository implementation, OpenTelemetry instrumentation, etc. 52 | 53 | * Implement event-driven architectural patterns in your application, such as Event Sourcing or CQRS. 54 | 55 | ### How to install 56 | 57 | You can add this library to your project by running: 58 | 59 | ```sh 60 | go get -u github.com/get-eventually/go-eventually 61 | ``` 62 | 63 | ## Contributing 64 | 65 | Thank you for your consideration ❤️ You can head over our [CONTRIBUTING](./CONTRIBUTING.md) page to get started. 66 | 67 | ## License 68 | 69 | This project is licensed under the [MIT license](LICENSE). 70 | 71 | ### Contribution 72 | 73 | Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in `go-eventually` by you, shall be licensed as MIT, without any additional terms or conditions. 74 | -------------------------------------------------------------------------------- /aggregate/aggregate.go: -------------------------------------------------------------------------------- 1 | package aggregate 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/get-eventually/go-eventually/event" 7 | "github.com/get-eventually/go-eventually/version" 8 | ) 9 | 10 | // ID represents an Aggregate ID type. 11 | // 12 | // Aggregate IDs should be able to be marshaled into a string format, 13 | // in order to be saved onto a named Event Stream. 14 | type ID interface { 15 | fmt.Stringer 16 | } 17 | 18 | // Aggregate is the segregated interface, part of the Aggregate Root interface, 19 | // that describes the left-folding behavior of Domain Events to update the 20 | // Aggregate Root state. 21 | type Aggregate interface { 22 | // Apply applies the specified Event to the Aggregate Root, 23 | // by causing a state change in the Aggregate Root instance. 24 | // 25 | // Since this method cause a state change, implementors should make sure 26 | // to use pointer semantics on their Aggregate Root method receivers. 27 | // 28 | // Please note, this method should not perform any kind of external request 29 | // and should be, save for the Aggregate Root state mutation, free of side effects. 30 | // For this reason, this method does not include a context.Context instance 31 | // in the input parameters. 32 | Apply(event.Event) error 33 | } 34 | 35 | // Internal contains some Aggregate Root methods that are used 36 | // by internal packages and modules for this library. 37 | // 38 | // Direct usage of these methods are discouraged. 39 | type Internal interface { 40 | FlushRecordedEvents() []event.Envelope 41 | } 42 | 43 | // Root is the interface describing an Aggregate Root instance. 44 | // 45 | // This interface should be implemented by your Aggregate Root types. 46 | // Make sure your Aggregate Root types embed the aggregate.BaseRoot type 47 | // to complete the implementation of this interface. 48 | type Root[I ID] interface { 49 | Aggregate 50 | Internal 51 | 52 | // AggregateID returns the Aggregate Root identifier. 53 | AggregateID() I 54 | 55 | // Version returns the current Aggregate Root version. 56 | // The version gets updated each time a new event is recorded 57 | // through the aggregate.RecordThat function. 58 | Version() version.Version 59 | 60 | setVersion(version.Version) 61 | recordThat(Aggregate, ...event.Envelope) error 62 | } 63 | 64 | // Type represents the type of an Aggregate, which will expose the 65 | // name of the Aggregate (used as Event Store type). 66 | // 67 | // If your Aggregate implementation uses pointers, use the factory to 68 | // return a non-nil instance of the type. 69 | type Type[I ID, T Root[I]] struct { 70 | Name string 71 | Factory func() T 72 | } 73 | 74 | // RecordThat records the Domain Event for the specified Aggregate Root. 75 | // 76 | // An error is typically returned if applying the Domain Event on the Aggregate 77 | // Root instance fails with an error. 78 | func RecordThat[I ID](root Root[I], events ...event.Envelope) error { 79 | return root.recordThat(root, events...) 80 | } 81 | 82 | // BaseRoot segregates and completes the aggregate.Root interface implementation 83 | // when embedded to a user-defined Aggregate Root type. 84 | // 85 | // BaseRoot provides some common traits, such as tracking the current Aggregate 86 | // Root version, and the recorded-but-uncommitted Domain Events, through 87 | // the aggregate.RecordThat function. 88 | type BaseRoot struct { 89 | version version.Version 90 | recordedEvents []event.Envelope 91 | } 92 | 93 | // Version returns the current version of the Aggregate Root instance. 94 | func (br BaseRoot) Version() version.Version { return br.version } 95 | 96 | // FlushRecordedEvents returns the list of uncommitted, recorded Domain Events 97 | // through the Aggregate Root. 98 | // 99 | // The internal list kept by aggregate.BaseRoot is reset. 100 | func (br *BaseRoot) FlushRecordedEvents() []event.Envelope { 101 | flushed := br.recordedEvents 102 | br.recordedEvents = nil 103 | 104 | return flushed 105 | } 106 | 107 | //nolint:unused // False positive. 108 | func (br *BaseRoot) setVersion(v version.Version) { 109 | br.version = v 110 | } 111 | 112 | //nolint:unused // False positive. 113 | func (br *BaseRoot) recordThat(aggregate Aggregate, events ...event.Envelope) error { 114 | for _, event := range events { 115 | if err := aggregate.Apply(event.Message); err != nil { 116 | return fmt.Errorf("aggregate.BaseRoot: failed to record event, %w", err) 117 | } 118 | 119 | br.recordedEvents = append(br.recordedEvents, event) 120 | br.version++ 121 | } 122 | 123 | return nil 124 | } 125 | -------------------------------------------------------------------------------- /aggregate/aggregate_test.go: -------------------------------------------------------------------------------- 1 | package aggregate_test 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/google/uuid" 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | 11 | "github.com/get-eventually/go-eventually/event" 12 | "github.com/get-eventually/go-eventually/internal/user" 13 | ) 14 | 15 | func TestRoot(t *testing.T) { 16 | var ( 17 | id = uuid.New() 18 | firstName = "John" 19 | lastName = "Doe" 20 | email = "john@doe.com" 21 | birthDate = time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC) 22 | now = time.Now() 23 | ) 24 | 25 | t.Run("create new aggregate root", func(t *testing.T) { 26 | usr, err := user.Create(id, firstName, lastName, email, birthDate, now) 27 | assert.NoError(t, err) 28 | 29 | expectedEvents := event.ToEnvelopes(&user.Event{ 30 | ID: id, 31 | RecordTime: now, 32 | Kind: &user.WasCreated{ 33 | FirstName: firstName, 34 | LastName: lastName, 35 | BirthDate: birthDate, 36 | Email: email, 37 | }, 38 | }) 39 | 40 | assert.Equal(t, expectedEvents, usr.FlushRecordedEvents()) 41 | }) 42 | 43 | t.Run("create new aggregate root with invalid fields", func(t *testing.T) { 44 | usr, err := user.Create(id, "", lastName, email, birthDate, now) 45 | assert.Error(t, err) 46 | assert.Nil(t, usr) 47 | }) 48 | 49 | t.Run("update an existing aggregate root", func(t *testing.T) { 50 | usr, err := user.Create(id, firstName, lastName, email, birthDate, now) 51 | require.NoError(t, err) 52 | usr.FlushRecordedEvents() // NOTE: flushing previously-recorded events to simulate fetching from a repository. 53 | 54 | newEmail := "john.doe@email.com" 55 | 56 | err = usr.UpdateEmail(newEmail, now, nil) 57 | assert.NoError(t, err) 58 | 59 | expectedEvents := event.ToEnvelopes(&user.Event{ 60 | ID: id, 61 | RecordTime: now, 62 | Kind: &user.EmailWasUpdated{Email: newEmail}, 63 | }) 64 | 65 | assert.Equal(t, expectedEvents, usr.FlushRecordedEvents()) 66 | }) 67 | } 68 | -------------------------------------------------------------------------------- /aggregate/doc.go: -------------------------------------------------------------------------------- 1 | // Package aggregate defines interfaces and types necessary to allow 2 | // users to define their own Aggregate types. 3 | package aggregate 4 | -------------------------------------------------------------------------------- /aggregate/event_sourced_repository.go: -------------------------------------------------------------------------------- 1 | package aggregate 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "golang.org/x/sync/errgroup" 8 | 9 | "github.com/get-eventually/go-eventually/event" 10 | "github.com/get-eventually/go-eventually/serde" 11 | "github.com/get-eventually/go-eventually/version" 12 | ) 13 | 14 | // RehydrateFromEvents rehydrates an Aggregate Root from a read-only Event Stream. 15 | func RehydrateFromEvents[I ID](root Root[I], eventStream event.StreamRead) error { 16 | for event := range eventStream { 17 | if err := root.Apply(event.Message); err != nil { 18 | return fmt.Errorf("aggregate.RehydrateFromEvents: failed to record event, %w", err) 19 | } 20 | 21 | root.setVersion(event.Version) 22 | } 23 | 24 | return nil 25 | } 26 | 27 | // RehydrateFromState rehydrates an aggregate.Root instance 28 | // using a state type, typically coming from an external state type (e.g. Protobuf type) 29 | // and aggregate.Repository implementation (e.g. postgres.AggregateRepository). 30 | func RehydrateFromState[I ID, Src Root[I], Dst any]( 31 | v version.Version, 32 | dst Dst, 33 | deserializer serde.Deserializer[Src, Dst], 34 | ) (Src, error) { 35 | var zeroValue Src 36 | 37 | src, err := deserializer.Deserialize(dst) 38 | if err != nil { 39 | return zeroValue, fmt.Errorf("aggregate.RehydrateFromState: failed to deserialize src into dst root, %w", err) 40 | } 41 | 42 | src.setVersion(v) 43 | 44 | return src, nil 45 | } 46 | 47 | // Factory is a function that creates new zero-valued 48 | // instances of an aggregate.Root implementation. 49 | type Factory[I ID, T Root[I]] func() T 50 | 51 | // EventSourcedRepository provides an aggregate.Repository interface implementation 52 | // that uses an event.Store to store and load the state of the Aggregate Root. 53 | type EventSourcedRepository[I ID, T Root[I]] struct { 54 | eventStore event.Store 55 | typ Type[I, T] 56 | } 57 | 58 | // NewEventSourcedRepository returns a new EventSourcedRepository implementation 59 | // to store and load Aggregate Roots, specified by the aggregate.Type, 60 | // using the provided event.Store implementation. 61 | func NewEventSourcedRepository[I ID, T Root[I]](eventStore event.Store, typ Type[I, T]) EventSourcedRepository[I, T] { 62 | return EventSourcedRepository[I, T]{ 63 | eventStore: eventStore, 64 | typ: typ, 65 | } 66 | } 67 | 68 | // Get returns the Aggregate Root with the specified id. 69 | // 70 | // aggregate.ErrRootNotFound is returned if no Aggregate Root was found with that id. 71 | // 72 | // An error is returned if the underlying Event Store fails, or if an error 73 | // occurs while trying to rehydrate the Aggregate Root state from its Event Stream. 74 | func (repo EventSourcedRepository[I, T]) Get(ctx context.Context, id I) (T, error) { 75 | var zeroValue T 76 | 77 | ctx, cancel := context.WithCancel(ctx) 78 | defer cancel() 79 | 80 | streamID := event.StreamID(id.String()) 81 | eventStream := make(event.Stream, 1) 82 | 83 | group, ctx := errgroup.WithContext(ctx) 84 | group.Go(func() error { 85 | if err := repo.eventStore.Stream(ctx, eventStream, streamID, version.SelectFromBeginning); err != nil { 86 | return fmt.Errorf("aggregate.EventSourcedRepository: failed while reading event from stream, %w", err) 87 | } 88 | 89 | return nil 90 | }) 91 | 92 | root := repo.typ.Factory() 93 | 94 | if err := RehydrateFromEvents(root, eventStream); err != nil { 95 | return zeroValue, fmt.Errorf("aggregate.EventSourcedRepository: failed to rehydrate aggregate root, %w", err) 96 | } 97 | 98 | if err := group.Wait(); err != nil { 99 | return zeroValue, err 100 | } 101 | 102 | if root.Version() == 0 { 103 | return zeroValue, ErrRootNotFound 104 | } 105 | 106 | return root, nil 107 | } 108 | 109 | // Save stores the Aggregate Root to the Event Store, by adding the 110 | // new, uncommitted Domain Events recorded through the Root, if any. 111 | // 112 | // An error is returned if the underlying Event Store fails. 113 | func (repo EventSourcedRepository[I, T]) Save(ctx context.Context, root T) error { 114 | events := root.FlushRecordedEvents() 115 | if len(events) == 0 { 116 | return nil 117 | } 118 | 119 | streamID := event.StreamID(root.AggregateID().String()) 120 | expectedVersion := version.CheckExact(root.Version() - version.Version(len(events))) //nolint:gosec // This should not overflow. 121 | 122 | if _, err := repo.eventStore.Append(ctx, streamID, expectedVersion, events...); err != nil { 123 | return fmt.Errorf("aggregate.EventSourcedRepository: failed to commit recorded events, %w", err) 124 | } 125 | 126 | return nil 127 | } 128 | -------------------------------------------------------------------------------- /aggregate/event_sourced_repository_test.go: -------------------------------------------------------------------------------- 1 | package aggregate_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | "github.com/google/uuid" 9 | "github.com/stretchr/testify/assert" 10 | 11 | "github.com/get-eventually/go-eventually/aggregate" 12 | "github.com/get-eventually/go-eventually/event" 13 | "github.com/get-eventually/go-eventually/internal/user" 14 | ) 15 | 16 | func TestEventSourcedRepository(t *testing.T) { 17 | var ( 18 | id = uuid.New() 19 | firstName = "John" 20 | lastName = "Doe" 21 | email = "john@doe.com" 22 | birthDate = time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC) 23 | now = time.Now() 24 | ) 25 | 26 | ctx := context.Background() 27 | eventStore := event.NewInMemoryStore() 28 | userRepository := aggregate.NewEventSourcedRepository(eventStore, user.Type) 29 | 30 | _, err := userRepository.Get(ctx, id) 31 | if !assert.ErrorIs(t, err, aggregate.ErrRootNotFound) { 32 | return 33 | } 34 | 35 | usr, err := user.Create(id, firstName, lastName, email, birthDate, now) 36 | if !assert.NoError(t, err) { 37 | return 38 | } 39 | 40 | err = userRepository.Save(ctx, usr) 41 | if !assert.NoError(t, err) { 42 | return 43 | } 44 | 45 | got, err := userRepository.Get(ctx, usr.AggregateID()) 46 | assert.NoError(t, err) 47 | assert.Equal(t, usr, got) 48 | } 49 | -------------------------------------------------------------------------------- /aggregate/repository.go: -------------------------------------------------------------------------------- 1 | package aggregate 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | ) 7 | 8 | // ErrRootNotFound is returned when the Aggregate Root requested 9 | // through a Repository was not found. 10 | var ErrRootNotFound = fmt.Errorf("aggregate: root not found") 11 | 12 | // Getter is an Aggregate Repository interface component, 13 | // that can be used for retrieving Aggregate Roots from some storage. 14 | type Getter[I ID, T Root[I]] interface { 15 | Get(ctx context.Context, id I) (T, error) 16 | } 17 | 18 | // Saver is an Aggregate Repository interface component, 19 | // that can be used for storing Aggregate Roots in some storage. 20 | type Saver[I ID, T Root[I]] interface { 21 | Save(ctx context.Context, root T) error 22 | } 23 | 24 | // Repository is an interface used to get Aggregate Roots from and save them to 25 | // some kind of storage, depending on the implementation. 26 | type Repository[I ID, T Root[I]] interface { 27 | Getter[I, T] 28 | Saver[I, T] 29 | } 30 | 31 | // FusedRepository is a convenience type that can be used to fuse together 32 | // different implementations for the Getter and Saver Repository interface components. 33 | type FusedRepository[I ID, T Root[I]] struct { 34 | Getter[I, T] 35 | Saver[I, T] 36 | } 37 | -------------------------------------------------------------------------------- /aggregate/scenario.go: -------------------------------------------------------------------------------- 1 | package aggregate 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | 9 | "github.com/get-eventually/go-eventually/event" 10 | "github.com/get-eventually/go-eventually/version" 11 | ) 12 | 13 | // ScenarioInit is the entrypoint of the Aggregate Root scenario API. 14 | // 15 | // An Aggregate Root scenario can either set the current evaluation context 16 | // by using Given(), or test a "clean-slate" scenario by using When() directly. 17 | type ScenarioInit[I ID, T Root[I]] struct { 18 | typ Type[I, T] 19 | } 20 | 21 | // Scenario is a scenario type to test the result of methods called 22 | // on an Aggregate Root and their effects. 23 | // 24 | // These methods are meant to produce side-effects in the Aggregate Root state, and thus 25 | // in the overall system, enforcing the aggregate invariants represented by the 26 | // Aggregate Root itself. 27 | func Scenario[I ID, T Root[I]](typ Type[I, T]) ScenarioInit[I, T] { 28 | return ScenarioInit[I, T]{ 29 | typ: typ, 30 | } 31 | } 32 | 33 | // Given allows to set an Aggregate Root state as precondition to the scenario test, 34 | // by specifying ordered Domain Events. 35 | func (sc ScenarioInit[I, T]) Given(events ...event.Persisted) ScenarioGiven[I, T] { 36 | return ScenarioGiven[I, T]{ 37 | typ: sc.typ, 38 | given: events, 39 | } 40 | } 41 | 42 | // When allows to call for a aggregate method/function that creates a new 43 | // Aggregate Root instance. 44 | // 45 | // This method requires a closure that return said new Aggregate Root instance 46 | // (hence why no input parameter) or an error. 47 | func (sc ScenarioInit[I, T]) When(fn func() (T, error)) ScenarioWhen[I, T] { 48 | return ScenarioWhen[I, T]{ 49 | typ: sc.typ, 50 | given: nil, 51 | fn: fn, 52 | } 53 | } 54 | 55 | // ScenarioGiven is the state of the scenario once the Aggregate Root 56 | // preconditions have been set through the Scenario().Given() method. 57 | // 58 | // This state gives access to the When() method to specify the aggregate method 59 | // to test using the desired Aggregate Root. 60 | type ScenarioGiven[I ID, T Root[I]] struct { 61 | typ Type[I, T] 62 | given []event.Persisted 63 | } 64 | 65 | // When allows to call the aggregate method method on the Aggregate Root instance 66 | // provided by the previous Scenario().Given() call. 67 | // 68 | // The aggregate method must be called inside the required closure parameter. 69 | func (sc ScenarioGiven[I, T]) When(fn func(T) error) ScenarioWhen[I, T] { 70 | return ScenarioWhen[I, T]{ 71 | typ: sc.typ, 72 | given: sc.given, 73 | fn: func() (T, error) { 74 | var zeroValue T 75 | 76 | root := sc.typ.Factory() 77 | eventStream := event.SliceToStream(sc.given) 78 | 79 | if err := RehydrateFromEvents[I](root, eventStream); err != nil { 80 | return zeroValue, err 81 | } 82 | 83 | if err := fn(root); err != nil { 84 | return zeroValue, err 85 | } 86 | 87 | return root, nil 88 | }, 89 | } 90 | } 91 | 92 | // ScenarioWhen is the state of the scenario once the aggregate method 93 | // to test has been provided through either Scenario().When() or 94 | // Scenario().Given().When() paths. 95 | // 96 | // This state allows to specify the expected outcome on the scenario using either 97 | // Then(), ThenFails(), ThenError() or ThenErrors() methods. 98 | type ScenarioWhen[I ID, T Root[I]] struct { 99 | typ Type[I, T] 100 | given []event.Persisted 101 | fn func() (T, error) 102 | } 103 | 104 | // Then specifies a successful outcome of the scenario, allowing to assert the 105 | // expected new Aggregate Root version and Domain Events recorded 106 | // during the aggregate method execution. 107 | func (sc ScenarioWhen[I, T]) Then(v version.Version, events ...event.Envelope) ScenarioThen[I, T] { 108 | return ScenarioThen[I, T]{ 109 | typ: sc.typ, 110 | given: sc.given, 111 | fn: sc.fn, 112 | version: v, 113 | expected: events, 114 | errors: nil, 115 | wantErr: false, 116 | } 117 | } 118 | 119 | // ThenFails specifies an unsuccessful outcome of the scenario, where the aggregate 120 | // method execution fails with an error. 121 | // 122 | // Use this method when there is no need to assert the error returned by the 123 | // aggregate method is of a specific type or value. 124 | func (sc ScenarioWhen[I, T]) ThenFails() ScenarioThen[I, T] { 125 | return ScenarioThen[I, T]{ 126 | typ: sc.typ, 127 | given: sc.given, 128 | fn: sc.fn, 129 | version: 0, 130 | expected: nil, 131 | errors: nil, 132 | wantErr: true, 133 | } 134 | } 135 | 136 | // ThenError specifies an unsuccessful outcome of the scenario, where the aggregate 137 | // method execution fails with an error. 138 | // 139 | // Use this method when you want to assert that the error retured by the aggregate 140 | // method execution is of a specific type or value. 141 | func (sc ScenarioWhen[I, T]) ThenError(err error) ScenarioThen[I, T] { 142 | return ScenarioThen[I, T]{ 143 | typ: sc.typ, 144 | given: sc.given, 145 | fn: sc.fn, 146 | version: 0, 147 | expected: nil, 148 | errors: []error{err}, 149 | wantErr: true, 150 | } 151 | } 152 | 153 | // ThenErrors specifies an unsuccessful outcome of the scenario, where the aggregate method 154 | // execution fails with a specific error that wraps multiple error types (e.g. through `errors.Join`). 155 | // 156 | // Use this method when you want to assert that the error returned by the aggregate method 157 | // matches ALL of the errors specified. 158 | func (sc ScenarioWhen[I, T]) ThenErrors(errs ...error) ScenarioThen[I, T] { 159 | return ScenarioThen[I, T]{ 160 | typ: sc.typ, 161 | given: sc.given, 162 | fn: sc.fn, 163 | version: 0, 164 | expected: nil, 165 | errors: errs, 166 | wantErr: true, 167 | } 168 | } 169 | 170 | // ScenarioThen is the state of the scenario where all parameters have 171 | // been set and it's ready to be executed using a testing.T instance. 172 | // 173 | // Use the AssertOn method to run the test scenario. 174 | type ScenarioThen[I ID, T Root[I]] struct { 175 | typ Type[I, T] 176 | given []event.Persisted 177 | fn func() (T, error) 178 | version version.Version 179 | expected []event.Envelope 180 | errors []error 181 | wantErr bool 182 | } 183 | 184 | // AssertOn runs the test scenario using the specified testing.T instance. 185 | func (sc ScenarioThen[I, T]) AssertOn(t *testing.T) { 186 | switch root, err := sc.fn(); { 187 | case sc.wantErr: 188 | assert.Error(t, err) 189 | 190 | if expected := errors.Join(sc.errors...); expected != nil { 191 | for _, expectedErr := range sc.errors { 192 | assert.ErrorIs(t, err, expectedErr) 193 | } 194 | } 195 | 196 | default: 197 | assert.NoError(t, err) 198 | 199 | recordedEvents := root.FlushRecordedEvents() 200 | assert.Equal(t, sc.expected, recordedEvents) 201 | assert.Equal(t, sc.version, root.Version()) 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /aggregate/scenario_test.go: -------------------------------------------------------------------------------- 1 | package aggregate_test 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/google/uuid" 8 | 9 | "github.com/get-eventually/go-eventually/aggregate" 10 | "github.com/get-eventually/go-eventually/event" 11 | "github.com/get-eventually/go-eventually/internal/user" 12 | ) 13 | 14 | func TestScenario(t *testing.T) { 15 | var ( 16 | id = uuid.New() 17 | firstName = "John" 18 | lastName = "Ross" 19 | birthDate = time.Date(1990, 1, 1, 0, 0, 0, 0, time.Local) 20 | email = "john@ross.com" 21 | now = time.Now() 22 | ) 23 | 24 | t.Run("test an aggregate function with one factory", func(t *testing.T) { 25 | aggregate. 26 | Scenario(user.Type). 27 | When(func() (*user.User, error) { 28 | return user.Create(id, firstName, lastName, email, birthDate, now) 29 | }). 30 | Then(1, event.ToEnvelope(&user.Event{ 31 | ID: id, 32 | RecordTime: now, 33 | Kind: &user.WasCreated{ 34 | FirstName: firstName, 35 | LastName: lastName, 36 | BirthDate: birthDate, 37 | Email: email, 38 | }, 39 | })). 40 | AssertOn(t) 41 | }) 42 | 43 | t.Run("test an aggregate function with one factory call that returns an error", func(t *testing.T) { 44 | aggregate. 45 | Scenario(user.Type). 46 | When(func() (*user.User, error) { 47 | return user.Create(id, "", lastName, email, birthDate, now) 48 | }). 49 | ThenFails(). 50 | AssertOn(t) 51 | }) 52 | 53 | t.Run("test an aggregate function with one factory call that returns a specific error", func(t *testing.T) { 54 | aggregate. 55 | Scenario(user.Type). 56 | When(func() (*user.User, error) { 57 | return user.Create(id, "", lastName, email, birthDate, now) 58 | }). 59 | ThenError(user.ErrInvalidFirstName). 60 | AssertOn(t) 61 | }) 62 | 63 | t.Run("test an aggregate function with one factory call that returns multiple errors with errors.Join()", func(t *testing.T) { //nolint:lll // It's ok in a test. 64 | aggregate. 65 | Scenario(user.Type). 66 | When(func() (*user.User, error) { 67 | return user.Create(id, "", "", "", time.Time{}, now) 68 | }). 69 | ThenErrors( 70 | user.ErrInvalidFirstName, 71 | user.ErrInvalidLastName, 72 | user.ErrInvalidEmail, 73 | user.ErrInvalidBirthDate, 74 | ). 75 | AssertOn(t) 76 | }) 77 | 78 | t.Run("test an aggregate function with an already-existing AggregateRoot instance", func(t *testing.T) { 79 | aggregate. 80 | Scenario(user.Type). 81 | Given(event.Persisted{ 82 | StreamID: event.StreamID(id.String()), 83 | Version: 1, 84 | Envelope: event.ToEnvelope(&user.Event{ 85 | ID: id, 86 | RecordTime: now, 87 | Kind: &user.WasCreated{ 88 | FirstName: firstName, 89 | LastName: lastName, 90 | BirthDate: birthDate, 91 | Email: email, 92 | }, 93 | }), 94 | }). 95 | When(func(u *user.User) error { 96 | return u.UpdateEmail("john.ross@email.com", now, nil) 97 | }). 98 | Then(2, event.ToEnvelope(&user.Event{ 99 | ID: id, 100 | RecordTime: now, 101 | Kind: &user.EmailWasUpdated{ 102 | Email: "john.ross@email.com", 103 | }, 104 | })). 105 | AssertOn(t) 106 | }) 107 | } 108 | -------------------------------------------------------------------------------- /command/command.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/get-eventually/go-eventually/message" 7 | ) 8 | 9 | // Command is a specific kind of Message that represents an intent. 10 | // Commands should be phrased in the present, imperative tense, such as "ActivateUser" or "CreateOrder". 11 | type Command message.Message 12 | 13 | // Envelope carries both a Command and some optional Metadata attached to it. 14 | type Envelope[T Command] message.Envelope[T] 15 | 16 | // ToGenericEnvelope returns a GenericEnvelope version of the current Envelope instance. 17 | func (cmd Envelope[T]) ToGenericEnvelope() GenericEnvelope { 18 | return GenericEnvelope{ 19 | Message: cmd.Message, 20 | Metadata: cmd.Metadata, 21 | } 22 | } 23 | 24 | // Handler is the interface that defines a Command Handler, 25 | // a component that receives a specific kind of Command 26 | // and executes the business logic related to that particular Command. 27 | type Handler[T Command] interface { 28 | Handle(ctx context.Context, cmd Envelope[T]) error 29 | } 30 | 31 | // HandlerFunc is a functional type that implements the Handler interface. 32 | // Useful for testing and stateless Handlers. 33 | type HandlerFunc[T Command] func(context.Context, Envelope[T]) error 34 | 35 | // Handle handles the provided Command through the functional Handler. 36 | func (fn HandlerFunc[T]) Handle(ctx context.Context, cmd Envelope[T]) error { 37 | return fn(ctx, cmd) 38 | } 39 | 40 | // GenericEnvelope is a Command Envelope that depends solely on the Command interface, 41 | // not a specific generic Command type. 42 | type GenericEnvelope Envelope[Command] 43 | 44 | // FromGenericEnvelope attempts to type-cast a GenericEnvelope instance into 45 | // a strongly-typed Command Envelope. 46 | // 47 | // A boolean guard is returned to signal whether the type-casting was successful 48 | // or not. 49 | func FromGenericEnvelope[T Command](cmd GenericEnvelope) (Envelope[T], bool) { 50 | if v, ok := cmd.Message.(T); ok { 51 | return Envelope[T]{ 52 | Message: v, 53 | Metadata: cmd.Metadata, 54 | }, true 55 | } 56 | 57 | return Envelope[T]{}, false //nolint:exhaustruct // This is a zero value anyway. 58 | } 59 | 60 | // ToEnvelope is a convenience function that wraps the provided Command type 61 | // into an Envelope, with no metadata attached to it. 62 | func ToEnvelope[T Command](cmd T) Envelope[T] { 63 | return Envelope[T]{ 64 | Message: cmd, 65 | Metadata: nil, 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /command/command_test.go: -------------------------------------------------------------------------------- 1 | package command_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | 8 | "github.com/get-eventually/go-eventually/command" 9 | ) 10 | 11 | var ( 12 | _ command.Command = commandTest1{} 13 | _ command.Command = commandTest2{} 14 | ) 15 | 16 | type commandTest1 struct{} 17 | 18 | func (commandTest1) Name() string { return "command_test_1" } 19 | 20 | type commandTest2 struct{} 21 | 22 | func (commandTest2) Name() string { return "command_test_2" } 23 | 24 | func TestGenericEnvelope(t *testing.T) { 25 | cmd1 := command.ToEnvelope(commandTest1{}) 26 | genericCmd1 := cmd1.ToGenericEnvelope() 27 | 28 | v1, ok := command.FromGenericEnvelope[commandTest1](genericCmd1) 29 | assert.Equal(t, cmd1, v1) 30 | assert.True(t, ok) 31 | 32 | v2, ok := command.FromGenericEnvelope[commandTest2](genericCmd1) 33 | assert.Zero(t, v2) 34 | assert.False(t, ok) 35 | } 36 | -------------------------------------------------------------------------------- /command/doc.go: -------------------------------------------------------------------------------- 1 | // Package command contains types and interfaces for implementing Command Handlers, 2 | // necessary for producing side effects in your Aggregates and system, 3 | // and implement your Domain's business logic. 4 | package command 5 | -------------------------------------------------------------------------------- /command/scenario.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | 11 | "github.com/get-eventually/go-eventually/event" 12 | "github.com/get-eventually/go-eventually/version" 13 | ) 14 | 15 | // ScenarioInit is the entrypoint of the Command Handler scenario API. 16 | // 17 | // A Command Handler scenario can either set the current evaluation context 18 | // by using Given(), or test a "clean-slate" scenario by using When() directly. 19 | type ScenarioInit[Cmd Command, T Handler[Cmd]] struct{} 20 | 21 | // Scenario is a scenario type to test the result of Commands 22 | // being handled by a Command Handler. 23 | // 24 | // Command Handlers in Event-sourced systems produce side effects by means 25 | // of Domain Events. This scenario API helps you with testing the Domain Events 26 | // produced by a Command Handler when handling a specific Command. 27 | func Scenario[Cmd Command, T Handler[Cmd]]() ScenarioInit[Cmd, T] { 28 | return ScenarioInit[Cmd, T]{} 29 | } 30 | 31 | // Given sets the Command Handler scenario preconditions. 32 | // 33 | // Domain Events are used in Event-sourced systems to represent a side effect 34 | // that has taken place in the system. In order to set a given state for the 35 | // system to be in while testing a specific Command evaluation, you should 36 | // specify the Domain Events that have happened thus far. 37 | // 38 | // When you're testing Commands with a clean-slate system, you should either specify 39 | // no Domain Events, or skip directly to When(). 40 | func (sc ScenarioInit[Cmd, T]) Given(events ...event.Persisted) ScenarioGiven[Cmd, T] { 41 | return ScenarioGiven[Cmd, T]{ 42 | given: events, 43 | } 44 | } 45 | 46 | // When provides the Command to evaluate. 47 | func (sc ScenarioInit[Cmd, T]) When(cmd Envelope[Cmd]) ScenarioWhen[Cmd, T] { 48 | return ScenarioWhen[Cmd, T]{ 49 | ScenarioGiven: ScenarioGiven[Cmd, T]{given: nil}, 50 | when: cmd, 51 | } 52 | } 53 | 54 | // ScenarioGiven is the state of the scenario once 55 | // a set of Domain Events have been provided using Given(), to represent 56 | // the state of the system at the time of evaluating a Command. 57 | type ScenarioGiven[Cmd Command, T Handler[Cmd]] struct { 58 | given []event.Persisted 59 | } 60 | 61 | // When provides the Command to evaluate. 62 | func (sc ScenarioGiven[Cmd, T]) When(cmd Envelope[Cmd]) ScenarioWhen[Cmd, T] { 63 | return ScenarioWhen[Cmd, T]{ 64 | ScenarioGiven: sc, 65 | when: cmd, 66 | } 67 | } 68 | 69 | // ScenarioWhen is the state of the scenario once the state of the 70 | // system and the Command to evaluate have been provided. 71 | type ScenarioWhen[Cmd Command, T Handler[Cmd]] struct { 72 | ScenarioGiven[Cmd, T] 73 | 74 | when Envelope[Cmd] 75 | } 76 | 77 | // Then sets a positive expectation on the scenario outcome, to produce 78 | // the Domain Events provided in input. 79 | // 80 | // The list of Domain Events specified should be ordered as the expected 81 | // order of recording by the Command Handler. 82 | func (sc ScenarioWhen[Cmd, T]) Then(events ...event.Persisted) ScenarioThen[Cmd, T] { 83 | return ScenarioThen[Cmd, T]{ 84 | ScenarioWhen: sc, 85 | then: events, 86 | errors: nil, 87 | wantErr: false, 88 | } 89 | } 90 | 91 | // ThenError sets a negative expectation on the scenario outcome, 92 | // to produce an error value that is similar to the one provided in input. 93 | // 94 | // Error assertion happens using errors.Is(), so the error returned 95 | // by the Command Handler is unwrapped until the cause error to match 96 | // the provided expectation. 97 | func (sc ScenarioWhen[Cmd, T]) ThenError(err error) ScenarioThen[Cmd, T] { 98 | return ScenarioThen[Cmd, T]{ 99 | ScenarioWhen: sc, 100 | then: nil, 101 | errors: []error{err}, 102 | wantErr: true, 103 | } 104 | } 105 | 106 | // ThenErrors specifies an unsuccessful outcome of the scenario, where the domain command 107 | // execution fails with a specific error that wraps multiple error types (e.g. through `errors.Join`). 108 | // 109 | // Use this method when you want to assert that the error returned by the domain command 110 | // matches ALL of the errors specified. 111 | func (sc ScenarioWhen[I, T]) ThenErrors(errs ...error) ScenarioThen[I, T] { 112 | return ScenarioThen[I, T]{ 113 | ScenarioWhen: sc, 114 | then: nil, 115 | errors: errs, 116 | wantErr: true, 117 | } 118 | } 119 | 120 | // ThenFails sets a negative expectation on the scenario outcome, 121 | // to fail the Command execution with no particular assertion on the error returned. 122 | // 123 | // This is useful when the error returned is not important for the Command 124 | // you're trying to test. 125 | func (sc ScenarioWhen[Cmd, T]) ThenFails() ScenarioThen[Cmd, T] { 126 | return ScenarioThen[Cmd, T]{ 127 | ScenarioWhen: sc, 128 | then: nil, 129 | errors: nil, 130 | wantErr: true, 131 | } 132 | } 133 | 134 | // ScenarioThen is the state of the scenario once the preconditions 135 | // and expectations have been fully specified. 136 | type ScenarioThen[Cmd Command, T Handler[Cmd]] struct { 137 | ScenarioWhen[Cmd, T] 138 | 139 | then []event.Persisted 140 | errors []error 141 | wantErr bool 142 | } 143 | 144 | // AssertOn performs the specified expectations of the scenario, using the Command Handler 145 | // instance produced by the provided factory function. 146 | // 147 | // A Command Handler should only use a single Aggregate type, to ensure that the 148 | // side effects happen in a well-defined transactional boundary. If your Command Handler 149 | // needs to modify more than one Aggregate, you might be doing something wrong 150 | // in your domain model. 151 | // 152 | // The type of the Aggregate used to evaluate the Command must be specified, 153 | // so that the Event-sourced Repository instance can be provided to the factory function 154 | // to build the desired Command Handler. 155 | func (sc ScenarioThen[Cmd, T]) AssertOn( 156 | t *testing.T, 157 | handlerFactory func(event.Store) T, 158 | ) { 159 | ctx := context.Background() 160 | store := event.NewInMemoryStore() 161 | 162 | for _, evt := range sc.given { 163 | _, err := store.Append(ctx, evt.StreamID, version.Any, evt.Envelope) 164 | require.NoError(t, err) 165 | } 166 | 167 | trackingStore := event.NewTrackingStore(store) 168 | handler := handlerFactory(event.FusedStore{ 169 | Appender: trackingStore, 170 | Streamer: store, 171 | }) 172 | 173 | switch err := handler.Handle(context.Background(), sc.when); { 174 | case sc.wantErr: 175 | assert.Error(t, err) 176 | 177 | if expected := errors.Join(sc.errors...); expected != nil { 178 | for _, expectedErr := range sc.errors { 179 | assert.ErrorIs(t, err, expectedErr) 180 | } 181 | } 182 | 183 | default: 184 | assert.NoError(t, err) 185 | assert.Equal(t, sc.then, trackingStore.Recorded()) 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /command/scenario_test.go: -------------------------------------------------------------------------------- 1 | package command_test 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/google/uuid" 8 | 9 | "github.com/get-eventually/go-eventually/aggregate" 10 | "github.com/get-eventually/go-eventually/command" 11 | "github.com/get-eventually/go-eventually/event" 12 | "github.com/get-eventually/go-eventually/internal/user" 13 | "github.com/get-eventually/go-eventually/version" 14 | ) 15 | 16 | func TestScenario(t *testing.T) { 17 | id := uuid.New() 18 | now := time.Now() 19 | 20 | makeCommandHandler := func(s event.Store) user.CreateCommandHandler { 21 | return user.CreateCommandHandler{ 22 | Clock: func() time.Time { return now }, 23 | UUIDGenerator: func() uuid.UUID { return id }, 24 | UserRepository: aggregate.NewEventSourcedRepository(s, user.Type), 25 | } 26 | } 27 | 28 | t.Run("fails when the given arguments are invalid", func(t *testing.T) { 29 | command. 30 | Scenario[user.CreateCommand, user.CreateCommandHandler](). 31 | When(command.Envelope[user.CreateCommand]{ 32 | Message: user.CreateCommand{ 33 | FirstName: "", 34 | LastName: "", 35 | BirthDate: time.Time{}, 36 | Email: "", 37 | }, 38 | Metadata: nil, 39 | }). 40 | ThenErrors( 41 | user.ErrInvalidFirstName, 42 | user.ErrInvalidLastName, 43 | user.ErrInvalidEmail, 44 | user.ErrInvalidBirthDate, 45 | ). 46 | AssertOn(t, makeCommandHandler) 47 | }) 48 | 49 | t.Run("create new user", func(t *testing.T) { 50 | command. 51 | Scenario[user.CreateCommand, user.CreateCommandHandler](). 52 | When(command.Envelope[user.CreateCommand]{ 53 | Message: user.CreateCommand{ 54 | FirstName: "John", 55 | LastName: "Doe", 56 | BirthDate: now, 57 | Email: "john@doe.com", 58 | }, 59 | Metadata: nil, 60 | }). 61 | Then(event.Persisted{ 62 | StreamID: event.StreamID(id.String()), 63 | Version: 1, 64 | Envelope: event.ToEnvelope(&user.Event{ 65 | ID: id, 66 | RecordTime: now, 67 | Kind: &user.WasCreated{ 68 | FirstName: "John", 69 | LastName: "Doe", 70 | BirthDate: now, 71 | Email: "john@doe.com", 72 | }, 73 | }), 74 | }). 75 | AssertOn(t, makeCommandHandler) 76 | }) 77 | 78 | t.Run("cannot create two duplicate users", func(t *testing.T) { 79 | command. 80 | Scenario[user.CreateCommand, user.CreateCommandHandler](). 81 | Given(event.Persisted{ 82 | StreamID: event.StreamID(id.String()), 83 | Version: 1, 84 | Envelope: event.ToEnvelope(&user.Event{ 85 | ID: id, 86 | RecordTime: now, 87 | Kind: &user.WasCreated{ 88 | FirstName: "John", 89 | LastName: "Doe", 90 | BirthDate: now, 91 | Email: "john@doe.com", 92 | }, 93 | }), 94 | }). 95 | When(command.Envelope[user.CreateCommand]{ 96 | Message: user.CreateCommand{ 97 | FirstName: "John", 98 | LastName: "Doe", 99 | BirthDate: now, 100 | Email: "john@doe.com", 101 | }, 102 | Metadata: nil, 103 | }). 104 | ThenError(version.ConflictError{ 105 | Expected: 0, 106 | Actual: 1, 107 | }). 108 | AssertOn(t, makeCommandHandler) 109 | }) 110 | } 111 | -------------------------------------------------------------------------------- /event/doc.go: -------------------------------------------------------------------------------- 1 | // Package event contains types and implementations for dealing with Domain Events. 2 | package event 3 | -------------------------------------------------------------------------------- /event/event.go: -------------------------------------------------------------------------------- 1 | package event 2 | 3 | import ( 4 | "github.com/get-eventually/go-eventually/message" 5 | "github.com/get-eventually/go-eventually/version" 6 | ) 7 | 8 | // Event is a Message representing some Domain information that has happened 9 | // in the past, which is of vital information to the Domain itself. 10 | // 11 | // Event type names should be phrased in the past tense, to enforce the notion 12 | // of "information happened in the past". 13 | type Event message.Message 14 | 15 | // Envelope contains a Domain Event and possible metadata associated to it. 16 | // 17 | // Due to lack of sum types (a.k.a enum types), Events cannot currently 18 | // take advantage of the new generics feature introduced with Go 1.18. 19 | type Envelope message.GenericEnvelope 20 | 21 | // StreamID identifies an Event Stream, which is a log of ordered Domain Events. 22 | type StreamID string 23 | 24 | // Persisted represents an Domain Event that has been persisted into the Event Store. 25 | type Persisted struct { 26 | StreamID 27 | version.Version 28 | Envelope 29 | } 30 | 31 | // ToEnvelope returns an Envelope instance with the provided Event 32 | // instance and no Metadata. 33 | func ToEnvelope(event Event) Envelope { 34 | return Envelope{ 35 | Message: event, 36 | Metadata: nil, 37 | } 38 | } 39 | 40 | // ToEnvelopes returns a list of Envelopes from a list of Events. 41 | // The returned Envelopes have no Metadata. 42 | func ToEnvelopes(events ...Event) []Envelope { 43 | envelopes := make([]Envelope, 0, len(events)) 44 | 45 | for _, event := range events { 46 | envelopes = append(envelopes, Envelope{ 47 | Message: event, 48 | Metadata: nil, 49 | }) 50 | } 51 | 52 | return envelopes 53 | } 54 | -------------------------------------------------------------------------------- /event/processor.go: -------------------------------------------------------------------------------- 1 | package event 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | // Processor represents a component that can process persisted Domain Events. 8 | type Processor interface { 9 | Process(ctx context.Context, event Persisted) error 10 | } 11 | 12 | // ProcessorFunc is a functional implementation of the Processor interface. 13 | type ProcessorFunc func(ctx context.Context, event Persisted) error 14 | 15 | // Process implements the event.Processor interface. 16 | func (pf ProcessorFunc) Process(ctx context.Context, event Persisted) error { 17 | return pf(ctx, event) 18 | } 19 | -------------------------------------------------------------------------------- /event/store.go: -------------------------------------------------------------------------------- 1 | package event 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/get-eventually/go-eventually/version" 7 | ) 8 | 9 | // Stream represents a stream of persisted Domain Events coming from some 10 | // stream-able source of data, like an Event Store. 11 | type Stream = chan Persisted 12 | 13 | // StreamWrite provides write-only access to an event.Stream object. 14 | type StreamWrite chan<- Persisted 15 | 16 | // StreamRead provides read-only access to an event.Stream object. 17 | type StreamRead <-chan Persisted 18 | 19 | // SliceToStream converts a slice of event.Persisted domain events to an event.Stream type. 20 | // 21 | // The event.Stream channel has the same buffer size as the input slice. 22 | // 23 | // The channel returned by the function contains all the original slice elements 24 | // and is already closed. 25 | func SliceToStream(events []Persisted) Stream { 26 | ch := make(chan Persisted, len(events)) 27 | defer close(ch) 28 | 29 | for _, event := range events { 30 | ch <- event 31 | } 32 | 33 | return ch 34 | } 35 | 36 | // Streamer is an event.Store trait used to open a specific Event Stream and stream it back 37 | // in the application. 38 | type Streamer interface { 39 | Stream(ctx context.Context, stream StreamWrite, id StreamID, selector version.Selector) error 40 | } 41 | 42 | // Appender is an event.Store trait used to append new Domain Events in the Event Stream. 43 | type Appender interface { 44 | Append(ctx context.Context, id StreamID, expected version.Check, events ...Envelope) (version.Version, error) 45 | } 46 | 47 | // Store represents an Event Store, a stateful data source where Domain Events 48 | // can be safely stored, and easily replayed. 49 | type Store interface { 50 | Appender 51 | Streamer 52 | } 53 | 54 | // FusedStore is a convenience type to fuse 55 | // multiple Event Store interfaces where you might need to extend 56 | // the functionality of the Store only partially. 57 | // 58 | // E.g. You might want to extend the functionality of the Append() method, 59 | // but keep the Streamer methods the same. 60 | // 61 | // If the extension wrapper does not support 62 | // the Streamer interface, you cannot use the extension wrapper instance as an 63 | // Event Store in certain cases (e.g. the Aggregate Repository). 64 | // 65 | // Using a FusedStore instance you can fuse both instances 66 | // together, and use it with the rest of the library ecosystem. 67 | type FusedStore struct { 68 | Appender 69 | Streamer 70 | } 71 | -------------------------------------------------------------------------------- /event/store_inmemory.go: -------------------------------------------------------------------------------- 1 | package event 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "sync" 7 | 8 | "github.com/get-eventually/go-eventually/version" 9 | ) 10 | 11 | // Interface implementation assertion. 12 | var _ Store = new(InMemoryStore) 13 | 14 | // InMemoryStore is a thread-safe, in-memory event.Store implementation. 15 | type InMemoryStore struct { 16 | mx sync.RWMutex 17 | events map[StreamID][]Envelope 18 | } 19 | 20 | // NewInMemoryStore creates a new event.InMemoryStore instance. 21 | func NewInMemoryStore() *InMemoryStore { 22 | return &InMemoryStore{ 23 | mx: sync.RWMutex{}, 24 | events: make(map[StreamID][]Envelope), 25 | } 26 | } 27 | 28 | func contextErr(ctx context.Context) error { 29 | if err := ctx.Err(); err != nil { 30 | return fmt.Errorf("event.InMemoryStore: context error, %w", err) 31 | } 32 | 33 | return nil 34 | } 35 | 36 | // Stream streams committed events in the Event Store onto the provided EventStream, 37 | // from the specified Global Sequence Number in `from`, based on the provided stream.Target. 38 | // 39 | // Note: this call is synchronous, and will return when all the Events 40 | // have been successfully written to the provided EventStream, or when 41 | // the context has been canceled. 42 | // 43 | // This method fails only when the context is canceled. 44 | func (es *InMemoryStore) Stream( 45 | ctx context.Context, 46 | eventStream StreamWrite, 47 | id StreamID, 48 | selector version.Selector, 49 | ) error { 50 | es.mx.RLock() 51 | defer es.mx.RUnlock() 52 | defer close(eventStream) 53 | 54 | events, ok := es.events[id] 55 | if !ok { 56 | return nil 57 | } 58 | 59 | for i, evt := range events { 60 | eventVersion := version.Version(i) + 1 //nolint:gosec // This should not overflow. 61 | 62 | if eventVersion < selector.From { 63 | continue 64 | } 65 | 66 | persistedEvent := Persisted{ 67 | Envelope: evt, 68 | StreamID: id, 69 | Version: eventVersion, 70 | } 71 | 72 | select { 73 | case eventStream <- persistedEvent: 74 | case <-ctx.Done(): 75 | return contextErr(ctx) 76 | } 77 | } 78 | 79 | return nil 80 | } 81 | 82 | // Append inserts the specified Domain Events into the Event Stream specified 83 | // by the current instance, returning the new version of the Event Stream. 84 | // 85 | // `version.CheckExact` can be specified to enable an Optimistic Concurrency check 86 | // on append, by using the expected version of the Event Stream prior 87 | // to appending the new Events. 88 | // 89 | // Alternatively, `version.Any` can be used if no Optimistic Concurrency check 90 | // should be carried out. 91 | // 92 | // An instance of `version.ConflictError` will be returned if the optimistic locking 93 | // version check fails against the current version of the Event Stream. 94 | func (es *InMemoryStore) Append( 95 | _ context.Context, 96 | id StreamID, 97 | expected version.Check, 98 | events ...Envelope, 99 | ) (version.Version, error) { 100 | es.mx.Lock() 101 | defer es.mx.Unlock() 102 | 103 | currentVersion := version.CheckExact(len(es.events[id])) //nolint:gosec // This should not overflow. 104 | 105 | if v, expectsExactVersion := expected.(version.CheckExact); expectsExactVersion && currentVersion != expected { 106 | return 0, fmt.Errorf("event.InMemoryStore: failed to append events, %w", version.ConflictError{ 107 | Expected: version.Version(v), 108 | Actual: version.Version(currentVersion), 109 | }) 110 | } 111 | 112 | es.events[id] = append(es.events[id], events...) 113 | newEventStreamVersion := version.Version(len(es.events[id])) //nolint:gosec // This should not overflow. 114 | 115 | return newEventStreamVersion, nil 116 | } 117 | -------------------------------------------------------------------------------- /event/store_tracking.go: -------------------------------------------------------------------------------- 1 | package event 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | 7 | "github.com/get-eventually/go-eventually/version" 8 | ) 9 | 10 | // TrackingStore is an Event Store wrapper to track the Events 11 | // committed to the inner Event Store. 12 | // 13 | // Useful for tests assertion. 14 | type TrackingStore struct { 15 | Appender 16 | 17 | mx sync.RWMutex 18 | recorded []Persisted 19 | } 20 | 21 | // NewTrackingStore wraps an Event Store to capture events that get 22 | // appended to it. 23 | func NewTrackingStore(appender Appender) *TrackingStore { 24 | return &TrackingStore{ 25 | Appender: appender, 26 | mx: sync.RWMutex{}, 27 | recorded: nil, 28 | } 29 | } 30 | 31 | // Recorded returns the list of Events that have been appended 32 | // to the Event Store. 33 | // 34 | // Please note: these events do not record the Sequence Number assigned by 35 | // the Event Store. Usually you should not need it in test assertions, since 36 | // the order of Events in the returned slice always follows the global order 37 | // of the Event Stream (or the Event Store). 38 | func (es *TrackingStore) Recorded() []Persisted { 39 | es.mx.RLock() 40 | defer es.mx.RUnlock() 41 | 42 | return es.recorded 43 | } 44 | 45 | // Append forwards the call to the wrapped Event Store instance and, 46 | // if the operation concludes successfully, records these events internally. 47 | // 48 | // The recorded events can be accessed by calling Recorded(). 49 | func (es *TrackingStore) Append( 50 | ctx context.Context, 51 | id StreamID, 52 | expected version.Check, 53 | events ...Envelope, 54 | ) (version.Version, error) { 55 | es.mx.Lock() 56 | defer es.mx.Unlock() 57 | 58 | v, err := es.Appender.Append(ctx, id, expected, events...) 59 | if err != nil { 60 | return v, err 61 | } 62 | 63 | previousVersion := v - version.Version(len(events)) //nolint:gosec // This should not overflow. 64 | 65 | for i, evt := range events { 66 | es.recorded = append(es.recorded, Persisted{ 67 | StreamID: id, 68 | Version: previousVersion + version.Version(i) + 1, //nolint:gosec // This should not overflow. 69 | Envelope: evt, 70 | }) 71 | } 72 | 73 | return v, err 74 | } 75 | -------------------------------------------------------------------------------- /firestore/doc.go: -------------------------------------------------------------------------------- 1 | // Package firestore implements go-eventually interfaces (such as event.Store) 2 | // using Google Cloud Firestore as backend. 3 | package firestore 4 | -------------------------------------------------------------------------------- /firestore/event_store.go: -------------------------------------------------------------------------------- 1 | package firestore 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | 8 | "cloud.google.com/go/firestore" 9 | "google.golang.org/api/iterator" 10 | "google.golang.org/grpc/codes" 11 | "google.golang.org/grpc/status" 12 | 13 | "github.com/get-eventually/go-eventually/event" 14 | "github.com/get-eventually/go-eventually/message" 15 | "github.com/get-eventually/go-eventually/serde" 16 | "github.com/get-eventually/go-eventually/version" 17 | ) 18 | 19 | //nolint:exhaustruct // Only used for interface assertion. 20 | var _ event.Store = EventStore{} 21 | 22 | // EventStore is an event.Store implementation using Google Cloud Firestore as backend. 23 | type EventStore struct { 24 | Client *firestore.Client 25 | Serde serde.Bytes[message.Message] 26 | } 27 | 28 | // NewEventStore creates a new EventStore instance. 29 | func NewEventStore(client *firestore.Client, msgSerde serde.Bytes[message.Message]) EventStore { 30 | return EventStore{Client: client, Serde: msgSerde} 31 | } 32 | 33 | func (es EventStore) eventsCollection() *firestore.CollectionRef { 34 | return es.Client.Collection("Events") 35 | } 36 | 37 | func (es EventStore) streamsCollection() *firestore.CollectionRef { 38 | return es.Client.Collection("EventStreams") 39 | } 40 | 41 | // Stream implements the event.Streamer interface. 42 | func (es EventStore) Stream( 43 | ctx context.Context, 44 | stream event.StreamWrite, 45 | id event.StreamID, 46 | selector version.Selector, 47 | ) error { 48 | defer close(stream) 49 | 50 | iter := es.eventsCollection(). 51 | Where("event_stream_id", "==", string(id)). 52 | Where("version", ">=", selector.From). 53 | OrderBy("version", firestore.Asc). 54 | Documents(ctx) 55 | 56 | defer iter.Stop() 57 | 58 | for { 59 | doc, err := iter.Next() 60 | if errors.Is(err, iterator.Done) { 61 | break 62 | } 63 | 64 | if err != nil { 65 | return fmt.Errorf("firestore.EventStore.Stream: failed while reading iterator, %w", err) 66 | } 67 | 68 | payload, ok := doc.Data()["payload"].([]byte) 69 | if !ok { 70 | return fmt.Errorf("firestore.EventStore.Stream: invalid payload type, expected: []byte, got: %T", doc.Data()["payload"]) 71 | } 72 | 73 | msg, err := es.Serde.Deserialize(payload) 74 | if err != nil { 75 | return fmt.Errorf("firestore.EventStore.Stream: failed to deserialize message payload, %w", err) 76 | } 77 | 78 | var metadata message.Metadata 79 | if v, ok := doc.Data()["metadata"].(message.Metadata); ok && v != nil { 80 | metadata = v 81 | } 82 | 83 | v, ok := doc.Data()["version"].(int64) 84 | if !ok { 85 | return fmt.Errorf("firestore.EventStore.Stream: invalid version type, expected: int64, got: %T", doc.Data()["version"]) 86 | } 87 | 88 | stream <- event.Persisted{ 89 | StreamID: id, 90 | Version: version.Version(v), //nolint:gosec // This should not overflow. 91 | Envelope: event.Envelope{ 92 | Message: msg, 93 | Metadata: metadata, 94 | }, 95 | } 96 | } 97 | 98 | return nil 99 | } 100 | 101 | func (es EventStore) checkAndUpsertEventStream( 102 | tx *firestore.Transaction, 103 | id event.StreamID, 104 | expected version.Check, 105 | newEventsLength int, 106 | ) (version.Version, error) { 107 | docRef := es.streamsCollection().Doc(string(id)) 108 | 109 | doc, err := tx.Get(docRef) 110 | if err != nil && status.Code(err) != codes.NotFound { 111 | return 0, fmt.Errorf("firestore.EventStore.Append: failed to get stream, %w", err) 112 | } 113 | 114 | var currentVersion version.Version 115 | 116 | if err == nil { 117 | lastVersion, ok := doc.Data()["last_version"].(int64) 118 | if !ok { 119 | return 0, fmt.Errorf("firestore.EventStore.Append: invalid last_version type, expected: int64, got: %T", doc.Data()["last_version"]) 120 | } 121 | 122 | currentVersion = version.Version(lastVersion) //nolint:gosec // This should not overflow. 123 | } 124 | 125 | if v, ok := expected.(version.CheckExact); ok && version.Version(v) != currentVersion { 126 | return 0, fmt.Errorf("firestore.EventStore.Append: version check failed, %w", version.ConflictError{ 127 | Expected: version.Version(v), 128 | Actual: currentVersion, 129 | }) 130 | } 131 | 132 | newVersion := currentVersion + version.Version(newEventsLength) //nolint:gosec // This should not overflow. 133 | 134 | if err := tx.Set(docRef, map[string]interface{}{ 135 | "last_version": newVersion, 136 | }); err != nil { 137 | return 0, fmt.Errorf("firestore.EventStore.Append: failed to update event stream, %w", err) 138 | } 139 | 140 | return currentVersion, nil 141 | } 142 | 143 | func (es EventStore) appendEvent(tx *firestore.Transaction, evt event.Persisted) error { 144 | id := fmt.Sprintf("%s@{%d}", evt.StreamID, evt.Version) 145 | docRef := es.eventsCollection().Doc(id) 146 | 147 | payload, err := es.Serde.Serialize(evt.Message) 148 | if err != nil { 149 | return fmt.Errorf("firestore.EventStore.appendEvent: failed to serialize message, %w", err) 150 | } 151 | 152 | if err := tx.Create(docRef, map[string]interface{}{ 153 | "event_stream_id": string(evt.StreamID), 154 | "version": evt.Version, 155 | "type": evt.Message.Name(), 156 | "metadata": evt.Metadata, 157 | "payload": payload, 158 | }); err != nil { 159 | return fmt.Errorf("firestore.EventStore.appendEvent: failed to append event, %w", err) 160 | } 161 | 162 | return nil 163 | } 164 | 165 | // Append implements the event.Appender interface. 166 | func (es EventStore) Append( 167 | ctx context.Context, 168 | id event.StreamID, 169 | expected version.Check, 170 | events ...event.Envelope, 171 | ) (version.Version, error) { 172 | var currentVersion version.Version 173 | 174 | if err := es.Client.RunTransaction(ctx, func(_ context.Context, tx *firestore.Transaction) error { 175 | var err error 176 | 177 | if currentVersion, err = es.checkAndUpsertEventStream(tx, id, expected, len(events)); err != nil { 178 | return err 179 | } 180 | 181 | for i, evt := range events { 182 | if err := es.appendEvent(tx, event.Persisted{ 183 | StreamID: id, 184 | Version: currentVersion + version.Version(i) + 1, //nolint:gosec // This should not overflow. 185 | Envelope: evt, 186 | }); err != nil { 187 | return err 188 | } 189 | } 190 | 191 | return nil 192 | }); err != nil { 193 | return 0, fmt.Errorf("firestore.EventStore.Append: failed to commit transaction, %w", err) 194 | } 195 | 196 | return currentVersion + version.Version(len(events)), nil //nolint:gosec // This should not overflow. 197 | } 198 | -------------------------------------------------------------------------------- /firestore/event_store_test.go: -------------------------------------------------------------------------------- 1 | package firestore_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "cloud.google.com/go/firestore" 8 | "github.com/stretchr/testify/require" 9 | "github.com/testcontainers/testcontainers-go/modules/gcloud" 10 | "google.golang.org/api/option" 11 | "google.golang.org/grpc" 12 | "google.golang.org/grpc/credentials/insecure" 13 | 14 | eventuallyfirestore "github.com/get-eventually/go-eventually/firestore" 15 | "github.com/get-eventually/go-eventually/internal/user" 16 | userv1 "github.com/get-eventually/go-eventually/internal/user/gen/user/v1" 17 | "github.com/get-eventually/go-eventually/serde" 18 | ) 19 | 20 | func TestEventStore(t *testing.T) { 21 | const projectID = "firestore-project" 22 | 23 | if testing.Short() { 24 | t.SkipNow() 25 | } 26 | 27 | ctx := context.Background() 28 | 29 | firestoreContainer, err := gcloud.RunFirestore( 30 | ctx, 31 | "google/cloud-sdk:469.0.0-emulators", 32 | gcloud.WithProjectID(projectID), 33 | ) 34 | require.NoError(t, err) 35 | 36 | // Clean up the container 37 | defer func() { 38 | require.NoError(t, firestoreContainer.Terminate(ctx)) 39 | }() 40 | 41 | conn, err := grpc.NewClient(firestoreContainer.URI, grpc.WithTransportCredentials(insecure.NewCredentials())) 42 | require.NoError(t, err) 43 | 44 | client, err := firestore.NewClient(ctx, projectID, option.WithGRPCConn(conn)) 45 | require.NoError(t, err) 46 | 47 | defer func() { 48 | require.NoError(t, client.Close()) 49 | }() 50 | 51 | eventStore := eventuallyfirestore.EventStore{ 52 | Client: client, 53 | Serde: serde.Chain( 54 | user.EventProtoSerde, 55 | serde.NewProtoJSON(func() *userv1.Event { return new(userv1.Event) }), 56 | ), 57 | } 58 | 59 | user.EventStoreSuite(eventStore)(t) 60 | } 61 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "inputs": { 5 | "systems": "systems" 6 | }, 7 | "locked": { 8 | "lastModified": 1731533236, 9 | "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 10 | "owner": "numtide", 11 | "repo": "flake-utils", 12 | "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "numtide", 17 | "repo": "flake-utils", 18 | "type": "github" 19 | } 20 | }, 21 | "nixpkgs": { 22 | "locked": { 23 | "lastModified": 1743583204, 24 | "narHash": "sha256-F7n4+KOIfWrwoQjXrL2wD9RhFYLs2/GGe/MQY1sSdlE=", 25 | "owner": "NixOS", 26 | "repo": "nixpkgs", 27 | "rev": "2c8d3f48d33929642c1c12cd243df4cc7d2ce434", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "owner": "NixOS", 32 | "ref": "nixos-unstable", 33 | "repo": "nixpkgs", 34 | "type": "github" 35 | } 36 | }, 37 | "root": { 38 | "inputs": { 39 | "flake-utils": "flake-utils", 40 | "nixpkgs": "nixpkgs" 41 | } 42 | }, 43 | "systems": { 44 | "locked": { 45 | "lastModified": 1681028828, 46 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 47 | "owner": "nix-systems", 48 | "repo": "default", 49 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 50 | "type": "github" 51 | }, 52 | "original": { 53 | "owner": "nix-systems", 54 | "repo": "default", 55 | "type": "github" 56 | } 57 | } 58 | }, 59 | "root": "root", 60 | "version": 7 61 | } 62 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | inputs = { 3 | nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; 4 | flake-utils.url = "github:numtide/flake-utils"; 5 | }; 6 | 7 | outputs = { nixpkgs, flake-utils, ... }: 8 | flake-utils.lib.eachDefaultSystem 9 | (system: 10 | let 11 | pkgs = import nixpkgs { 12 | inherit system; 13 | config.allowUnfree = true; 14 | }; 15 | 16 | go = pkgs.go_1_24; 17 | withOurGoVersion = pkg: pkg.override { buildGoModule = pkgs.buildGo124Module; }; 18 | 19 | gopls = withOurGoVersion pkgs.gopls; 20 | delve = withOurGoVersion pkgs.delve; 21 | in 22 | { 23 | devShells.default = with pkgs; mkShell { 24 | packages = [ 25 | go 26 | buf 27 | ] ++ [ 28 | gopls 29 | delve 30 | goreleaser 31 | ] ++ (map withOurGoVersion [ 32 | gotools 33 | go-outline 34 | gopkgs 35 | ]) ++ [ 36 | git 37 | nil 38 | golangci-lint 39 | markdownlint-cli 40 | ]; 41 | 42 | # Provide binary paths for tooling through environment variables. 43 | GO_BIN_PATH = "${go}/bin/go"; 44 | GOPLS_PATH = "${gopls}/bin/gopls"; 45 | DLV_PATH = "${delve}/bin/dlv"; 46 | }; 47 | } 48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/get-eventually/go-eventually 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.24.2 6 | 7 | require ( 8 | cloud.google.com/go/firestore v1.18.0 9 | github.com/golang-migrate/migrate/v4 v4.18.2 10 | github.com/google/uuid v1.6.0 11 | github.com/jackc/pgx/v5 v5.7.4 12 | github.com/stretchr/testify v1.10.0 13 | github.com/testcontainers/testcontainers-go v0.36.0 14 | github.com/testcontainers/testcontainers-go/modules/gcloud v0.36.0 15 | github.com/testcontainers/testcontainers-go/modules/postgres v0.36.0 16 | go.opentelemetry.io/otel v1.35.0 17 | go.opentelemetry.io/otel/metric v1.35.0 18 | go.opentelemetry.io/otel/trace v1.35.0 19 | golang.org/x/sync v0.13.0 20 | google.golang.org/api v0.228.0 21 | google.golang.org/genproto v0.0.0-20250404141209-ee84b53bf3d0 22 | google.golang.org/grpc v1.71.1 23 | google.golang.org/protobuf v1.36.6 24 | ) 25 | 26 | require ( 27 | cloud.google.com/go v0.120.0 // indirect 28 | cloud.google.com/go/auth v0.15.0 // indirect 29 | cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect 30 | cloud.google.com/go/compute/metadata v0.6.0 // indirect 31 | cloud.google.com/go/longrunning v0.6.6 // indirect 32 | dario.cat/mergo v1.0.1 // indirect 33 | github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 // indirect 34 | github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect 35 | github.com/GoogleCloudPlatform/grpc-gcp-go/grpcgcp v1.5.2 // indirect 36 | github.com/Microsoft/go-winio v0.6.2 // indirect 37 | github.com/apache/arrow/go/v15 v15.0.2 // indirect 38 | github.com/cenkalti/backoff/v4 v4.3.0 // indirect 39 | github.com/containerd/log v0.1.0 // indirect 40 | github.com/containerd/platforms v0.2.1 // indirect 41 | github.com/cpuguy83/dockercfg v0.3.2 // indirect 42 | github.com/davecgh/go-spew v1.1.1 // indirect 43 | github.com/distribution/reference v0.6.0 // indirect 44 | github.com/docker/docker v28.0.1+incompatible // indirect 45 | github.com/docker/go-connections v0.5.0 // indirect 46 | github.com/docker/go-units v0.5.0 // indirect 47 | github.com/ebitengine/purego v0.8.2 // indirect 48 | github.com/felixge/httpsnoop v1.0.4 // indirect 49 | github.com/go-logr/logr v1.4.2 // indirect 50 | github.com/go-logr/stdr v1.2.2 // indirect 51 | github.com/go-ole/go-ole v1.3.0 // indirect 52 | github.com/gogo/protobuf v1.3.2 // indirect 53 | github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect 54 | github.com/google/s2a-go v0.1.9 // indirect 55 | github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect 56 | github.com/googleapis/gax-go/v2 v2.14.1 // indirect 57 | github.com/hashicorp/errwrap v1.1.0 // indirect 58 | github.com/hashicorp/go-multierror v1.1.1 // indirect 59 | github.com/jackc/chunkreader/v2 v2.0.1 // indirect 60 | github.com/jackc/pgconn v1.14.3 // indirect 61 | github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438 // indirect 62 | github.com/jackc/pgio v1.0.0 // indirect 63 | github.com/jackc/pgpassfile v1.0.0 // indirect 64 | github.com/jackc/pgproto3/v2 v2.3.3 // indirect 65 | github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect 66 | github.com/jackc/pgtype v1.14.4 // indirect 67 | github.com/jackc/pgx/v4 v4.18.3 // indirect 68 | github.com/jackc/puddle/v2 v2.2.2 // indirect 69 | github.com/klauspost/compress v1.17.11 // indirect 70 | github.com/lib/pq v1.10.9 // indirect 71 | github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 // indirect 72 | github.com/magiconair/properties v1.8.9 // indirect 73 | github.com/moby/docker-image-spec v1.3.1 // indirect 74 | github.com/moby/patternmatcher v0.6.0 // indirect 75 | github.com/moby/sys/sequential v0.6.0 // indirect 76 | github.com/moby/sys/user v0.3.0 // indirect 77 | github.com/moby/sys/userns v0.1.0 // indirect 78 | github.com/moby/term v0.5.0 // indirect 79 | github.com/morikuni/aec v1.0.0 // indirect 80 | github.com/opencontainers/go-digest v1.0.0 // indirect 81 | github.com/opencontainers/image-spec v1.1.1 // indirect 82 | github.com/pkg/errors v0.9.1 // indirect 83 | github.com/pmezard/go-difflib v1.0.0 // indirect 84 | github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect 85 | github.com/shirou/gopsutil/v3 v3.24.5 // indirect 86 | github.com/shirou/gopsutil/v4 v4.25.1 // indirect 87 | github.com/shoenig/go-m1cpu v0.1.6 // indirect 88 | github.com/sirupsen/logrus v1.9.3 // indirect 89 | github.com/tklauser/go-sysconf v0.3.14 // indirect 90 | github.com/tklauser/numcpus v0.9.0 // indirect 91 | github.com/yusufpapurcu/wmi v1.2.4 // indirect 92 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect 93 | go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0 // indirect 94 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 // indirect 95 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 // indirect 96 | go.uber.org/atomic v1.11.0 // indirect 97 | golang.org/x/crypto v0.36.0 // indirect 98 | golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67 // indirect 99 | golang.org/x/net v0.37.0 // indirect 100 | golang.org/x/oauth2 v0.28.0 // indirect 101 | golang.org/x/sys v0.31.0 // indirect 102 | golang.org/x/text v0.23.0 // indirect 103 | golang.org/x/time v0.11.0 // indirect 104 | golang.org/x/tools v0.28.0 // indirect 105 | google.golang.org/genproto/googleapis/api v0.0.0-20250324211829-b45e905df463 // indirect 106 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect 107 | gopkg.in/yaml.v3 v3.0.1 // indirect 108 | ) 109 | -------------------------------------------------------------------------------- /internal/user/aggregate.go: -------------------------------------------------------------------------------- 1 | // Package user serves as a small domain example of how to model 2 | // an Aggregate using go-eventually. 3 | // 4 | // This package is used for integration tests in the parent module. 5 | package user 6 | 7 | import ( 8 | "errors" 9 | "fmt" 10 | "time" 11 | 12 | "github.com/google/uuid" 13 | 14 | "github.com/get-eventually/go-eventually/aggregate" 15 | "github.com/get-eventually/go-eventually/event" 16 | "github.com/get-eventually/go-eventually/message" 17 | ) 18 | 19 | // Type is the User aggregate type. 20 | var Type = aggregate.Type[uuid.UUID, *User]{ 21 | Name: "User", 22 | Factory: func() *User { return new(User) }, 23 | } 24 | 25 | // User is a naive user implementation, modeled as an Aggregate 26 | // using go-eventually's API. 27 | type User struct { 28 | aggregate.BaseRoot 29 | 30 | // Aggregate field should remain unexported if possible, 31 | // to enforce encapsulation. 32 | 33 | id uuid.UUID 34 | firstName string 35 | lastName string 36 | birthDate time.Time 37 | email string 38 | } 39 | 40 | // Apply implements aggregate.Aggregate. 41 | func (user *User) Apply(evt event.Event) error { 42 | userEvent, ok := evt.(*Event) 43 | if !ok { 44 | return fmt.Errorf("user.Apply: unexpected event type, %T", evt) 45 | } 46 | 47 | switch kind := userEvent.Kind.(type) { 48 | case *WasCreated: 49 | user.id = userEvent.ID 50 | user.firstName = kind.FirstName 51 | user.lastName = kind.LastName 52 | user.birthDate = kind.BirthDate 53 | user.email = kind.Email 54 | case *EmailWasUpdated: 55 | user.email = kind.Email 56 | default: 57 | return fmt.Errorf("user.Apply: unexpected event kind type, %T", kind) 58 | } 59 | 60 | return nil 61 | } 62 | 63 | // AggregateID implements aggregate.Root. 64 | func (user *User) AggregateID() uuid.UUID { 65 | return user.id 66 | } 67 | 68 | // All the errors returned by User methods. 69 | var ( 70 | ErrInvalidFirstName = errors.New("user: invalid first name, is empty") 71 | ErrInvalidLastName = errors.New("user: invalid last name, is empty") 72 | ErrInvalidEmail = errors.New("user: invalid email name, is empty") 73 | ErrInvalidBirthDate = errors.New("user: invalid birthdate, is empty") 74 | ) 75 | 76 | // Create creates a new User using the provided input. 77 | func Create(id uuid.UUID, firstName, lastName, email string, birthDate, now time.Time) (*User, error) { 78 | var invalidArgs []error 79 | 80 | if firstName == "" { 81 | invalidArgs = append(invalidArgs, ErrInvalidFirstName) 82 | } 83 | 84 | if lastName == "" { 85 | invalidArgs = append(invalidArgs, ErrInvalidLastName) 86 | } 87 | 88 | if email == "" { 89 | invalidArgs = append(invalidArgs, ErrInvalidEmail) 90 | } 91 | 92 | if birthDate.IsZero() { 93 | invalidArgs = append(invalidArgs, ErrInvalidBirthDate) 94 | } 95 | 96 | if err := errors.Join(invalidArgs...); err != nil { 97 | return nil, fmt.Errorf("user.Create: invalid arguments provided, %w", err) 98 | } 99 | 100 | user := new(User) 101 | 102 | if err := aggregate.RecordThat(user, event.ToEnvelope(&Event{ 103 | ID: id, 104 | RecordTime: now, 105 | Kind: &WasCreated{ 106 | FirstName: firstName, 107 | LastName: lastName, 108 | BirthDate: birthDate, 109 | Email: email, 110 | }, 111 | })); err != nil { 112 | return nil, fmt.Errorf("user.Create: failed to record domain event, %w", err) 113 | } 114 | 115 | return user, nil 116 | } 117 | 118 | // UpdateEmail updates the User email with the specified one. 119 | func (user *User) UpdateEmail(email string, now time.Time, metadata message.Metadata) error { 120 | if email == "" { 121 | return ErrInvalidEmail 122 | } 123 | 124 | if err := aggregate.RecordThat(user, event.Envelope{ 125 | Metadata: metadata, 126 | Message: &Event{ 127 | ID: user.id, 128 | RecordTime: now, 129 | Kind: &EmailWasUpdated{Email: email}, 130 | }, 131 | }); err != nil { 132 | return fmt.Errorf("user.UpdateEmail: failed to record domain event, %w", err) 133 | } 134 | 135 | return nil 136 | } 137 | -------------------------------------------------------------------------------- /internal/user/buf.gen.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | version: v1 3 | managed: 4 | enabled: true 5 | go_package_prefix: 6 | default: github.com/get-eventually/go-eventually/internal/user/gen 7 | except: 8 | - buf.build/googleapis/googleapis 9 | plugins: 10 | - plugin: buf.build/protocolbuffers/go:v1.31.0 11 | out: gen 12 | opt: paths=source_relative 13 | - plugin: buf.build/connectrpc/go:v1.12.0 14 | out: gen 15 | opt: paths=source_relative 16 | -------------------------------------------------------------------------------- /internal/user/create_user.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/google/uuid" 9 | 10 | "github.com/get-eventually/go-eventually/aggregate" 11 | "github.com/get-eventually/go-eventually/command" 12 | ) 13 | 14 | //nolint:exhaustruct // Interface implementation assertion. 15 | var ( 16 | _ command.Command = CreateCommand{} 17 | _ command.Handler[CreateCommand] = CreateCommandHandler{} 18 | ) 19 | 20 | // CreateCommand is a domain command that can be used to create a new User. 21 | type CreateCommand struct { 22 | FirstName, LastName string 23 | BirthDate time.Time 24 | Email string 25 | } 26 | 27 | // Name implements command.Command. 28 | func (CreateCommand) Name() string { return "CreateUser" } 29 | 30 | // CreateCommandHandler is the command handler for CreateCommand domain commands. 31 | type CreateCommandHandler struct { 32 | Clock func() time.Time 33 | UUIDGenerator func() uuid.UUID 34 | UserRepository aggregate.Saver[uuid.UUID, *User] 35 | } 36 | 37 | // Handle implements command.Handler. 38 | func (h CreateCommandHandler) Handle(ctx context.Context, cmd command.Envelope[CreateCommand]) error { 39 | newUserID := h.UUIDGenerator() 40 | 41 | user, err := Create( 42 | newUserID, 43 | cmd.Message.FirstName, 44 | cmd.Message.LastName, 45 | cmd.Message.Email, 46 | cmd.Message.BirthDate, 47 | h.Clock(), 48 | ) 49 | if err != nil { 50 | return fmt.Errorf("user.CreateCommandHandler: failed to create new User, %w", err) 51 | } 52 | 53 | if err := h.UserRepository.Save(ctx, user); err != nil { 54 | return fmt.Errorf("user.CreateCommandHandler: failed to save new User to repository, %w", err) 55 | } 56 | 57 | return nil 58 | } 59 | -------------------------------------------------------------------------------- /internal/user/event.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/google/uuid" 7 | 8 | "github.com/get-eventually/go-eventually/event" 9 | ) 10 | 11 | var _ event.Event = new(Event) 12 | 13 | // Event represents a Domain Event for a User resource. 14 | type Event struct { 15 | ID uuid.UUID 16 | RecordTime time.Time 17 | Kind eventKind 18 | } 19 | 20 | // Name implements event.Event. 21 | func (evt *Event) Name() string { return evt.Kind.Name() } 22 | 23 | type eventKind interface { 24 | event.Event 25 | isEventKind() 26 | } 27 | 28 | var ( 29 | _ eventKind = new(WasCreated) 30 | _ eventKind = new(EmailWasUpdated) 31 | ) 32 | 33 | // WasCreated is the domain event fired after a User is created. 34 | type WasCreated struct { 35 | FirstName string 36 | LastName string 37 | BirthDate time.Time 38 | Email string 39 | } 40 | 41 | // Name implements message.Message. 42 | func (*WasCreated) Name() string { return "UserWasCreated" } 43 | func (*WasCreated) isEventKind() {} 44 | 45 | // EmailWasUpdated is the domain event fired after a User email is updated. 46 | type EmailWasUpdated struct { 47 | Email string 48 | } 49 | 50 | // Name implements message.Message. 51 | func (*EmailWasUpdated) Name() string { return "UserEmailWasUpdated" } 52 | func (*EmailWasUpdated) isEventKind() {} 53 | -------------------------------------------------------------------------------- /internal/user/gen/user/v1/event.pb.go: -------------------------------------------------------------------------------- 1 | // Code generated by protoc-gen-go. DO NOT EDIT. 2 | // versions: 3 | // protoc-gen-go v1.31.0 4 | // protoc (unknown) 5 | // source: user/v1/event.proto 6 | 7 | package userv1 8 | 9 | import ( 10 | date "google.golang.org/genproto/googleapis/type/date" 11 | protoreflect "google.golang.org/protobuf/reflect/protoreflect" 12 | protoimpl "google.golang.org/protobuf/runtime/protoimpl" 13 | timestamppb "google.golang.org/protobuf/types/known/timestamppb" 14 | reflect "reflect" 15 | sync "sync" 16 | ) 17 | 18 | const ( 19 | // Verify that this generated code is sufficiently up-to-date. 20 | _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) 21 | // Verify that runtime/protoimpl is sufficiently up-to-date. 22 | _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) 23 | ) 24 | 25 | // Represents a domain event for a specific User. 26 | type Event struct { 27 | state protoimpl.MessageState 28 | sizeCache protoimpl.SizeCache 29 | unknownFields protoimpl.UnknownFields 30 | 31 | // The primary identifier for the User. 32 | Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` 33 | // The timestamp of when the domain event has been recorded. 34 | RecordTime *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=record_time,json=recordTime,proto3" json:"record_time,omitempty"` 35 | // All the possible kind of User domain event supported. 36 | // 37 | // Types that are assignable to Kind: 38 | // 39 | // *Event_WasCreated_ 40 | // *Event_EmailWasUpdated_ 41 | Kind isEvent_Kind `protobuf_oneof:"kind"` 42 | } 43 | 44 | func (x *Event) Reset() { 45 | *x = Event{} 46 | if protoimpl.UnsafeEnabled { 47 | mi := &file_user_v1_event_proto_msgTypes[0] 48 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 49 | ms.StoreMessageInfo(mi) 50 | } 51 | } 52 | 53 | func (x *Event) String() string { 54 | return protoimpl.X.MessageStringOf(x) 55 | } 56 | 57 | func (*Event) ProtoMessage() {} 58 | 59 | func (x *Event) ProtoReflect() protoreflect.Message { 60 | mi := &file_user_v1_event_proto_msgTypes[0] 61 | if protoimpl.UnsafeEnabled && x != nil { 62 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 63 | if ms.LoadMessageInfo() == nil { 64 | ms.StoreMessageInfo(mi) 65 | } 66 | return ms 67 | } 68 | return mi.MessageOf(x) 69 | } 70 | 71 | // Deprecated: Use Event.ProtoReflect.Descriptor instead. 72 | func (*Event) Descriptor() ([]byte, []int) { 73 | return file_user_v1_event_proto_rawDescGZIP(), []int{0} 74 | } 75 | 76 | func (x *Event) GetId() string { 77 | if x != nil { 78 | return x.Id 79 | } 80 | return "" 81 | } 82 | 83 | func (x *Event) GetRecordTime() *timestamppb.Timestamp { 84 | if x != nil { 85 | return x.RecordTime 86 | } 87 | return nil 88 | } 89 | 90 | func (m *Event) GetKind() isEvent_Kind { 91 | if m != nil { 92 | return m.Kind 93 | } 94 | return nil 95 | } 96 | 97 | func (x *Event) GetWasCreated() *Event_WasCreated { 98 | if x, ok := x.GetKind().(*Event_WasCreated_); ok { 99 | return x.WasCreated 100 | } 101 | return nil 102 | } 103 | 104 | func (x *Event) GetEmailWasUpdated() *Event_EmailWasUpdated { 105 | if x, ok := x.GetKind().(*Event_EmailWasUpdated_); ok { 106 | return x.EmailWasUpdated 107 | } 108 | return nil 109 | } 110 | 111 | type isEvent_Kind interface { 112 | isEvent_Kind() 113 | } 114 | 115 | type Event_WasCreated_ struct { 116 | // When a new User has been created. 117 | WasCreated *Event_WasCreated `protobuf:"bytes,3,opt,name=was_created,json=wasCreated,proto3,oneof"` 118 | } 119 | 120 | type Event_EmailWasUpdated_ struct { 121 | // When a User email has been updated. 122 | EmailWasUpdated *Event_EmailWasUpdated `protobuf:"bytes,4,opt,name=email_was_updated,json=emailWasUpdated,proto3,oneof"` 123 | } 124 | 125 | func (*Event_WasCreated_) isEvent_Kind() {} 126 | 127 | func (*Event_EmailWasUpdated_) isEvent_Kind() {} 128 | 129 | // Specified that a new User was created. 130 | type Event_WasCreated struct { 131 | state protoimpl.MessageState 132 | sizeCache protoimpl.SizeCache 133 | unknownFields protoimpl.UnknownFields 134 | 135 | // The User's first name. 136 | FirstName string `protobuf:"bytes,1,opt,name=first_name,json=firstName,proto3" json:"first_name,omitempty"` 137 | // The User's last name. 138 | LastName string `protobuf:"bytes,2,opt,name=last_name,json=lastName,proto3" json:"last_name,omitempty"` 139 | // The User's birth name. 140 | BirthDate *date.Date `protobuf:"bytes,3,opt,name=birth_date,json=birthDate,proto3" json:"birth_date,omitempty"` 141 | // The User's email. 142 | Email string `protobuf:"bytes,4,opt,name=email,proto3" json:"email,omitempty"` 143 | } 144 | 145 | func (x *Event_WasCreated) Reset() { 146 | *x = Event_WasCreated{} 147 | if protoimpl.UnsafeEnabled { 148 | mi := &file_user_v1_event_proto_msgTypes[1] 149 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 150 | ms.StoreMessageInfo(mi) 151 | } 152 | } 153 | 154 | func (x *Event_WasCreated) String() string { 155 | return protoimpl.X.MessageStringOf(x) 156 | } 157 | 158 | func (*Event_WasCreated) ProtoMessage() {} 159 | 160 | func (x *Event_WasCreated) ProtoReflect() protoreflect.Message { 161 | mi := &file_user_v1_event_proto_msgTypes[1] 162 | if protoimpl.UnsafeEnabled && x != nil { 163 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 164 | if ms.LoadMessageInfo() == nil { 165 | ms.StoreMessageInfo(mi) 166 | } 167 | return ms 168 | } 169 | return mi.MessageOf(x) 170 | } 171 | 172 | // Deprecated: Use Event_WasCreated.ProtoReflect.Descriptor instead. 173 | func (*Event_WasCreated) Descriptor() ([]byte, []int) { 174 | return file_user_v1_event_proto_rawDescGZIP(), []int{0, 0} 175 | } 176 | 177 | func (x *Event_WasCreated) GetFirstName() string { 178 | if x != nil { 179 | return x.FirstName 180 | } 181 | return "" 182 | } 183 | 184 | func (x *Event_WasCreated) GetLastName() string { 185 | if x != nil { 186 | return x.LastName 187 | } 188 | return "" 189 | } 190 | 191 | func (x *Event_WasCreated) GetBirthDate() *date.Date { 192 | if x != nil { 193 | return x.BirthDate 194 | } 195 | return nil 196 | } 197 | 198 | func (x *Event_WasCreated) GetEmail() string { 199 | if x != nil { 200 | return x.Email 201 | } 202 | return "" 203 | } 204 | 205 | // Specifies that the email of an existing User was updated. 206 | type Event_EmailWasUpdated struct { 207 | state protoimpl.MessageState 208 | sizeCache protoimpl.SizeCache 209 | unknownFields protoimpl.UnknownFields 210 | 211 | // The new User email. 212 | Email string `protobuf:"bytes,1,opt,name=email,proto3" json:"email,omitempty"` 213 | } 214 | 215 | func (x *Event_EmailWasUpdated) Reset() { 216 | *x = Event_EmailWasUpdated{} 217 | if protoimpl.UnsafeEnabled { 218 | mi := &file_user_v1_event_proto_msgTypes[2] 219 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 220 | ms.StoreMessageInfo(mi) 221 | } 222 | } 223 | 224 | func (x *Event_EmailWasUpdated) String() string { 225 | return protoimpl.X.MessageStringOf(x) 226 | } 227 | 228 | func (*Event_EmailWasUpdated) ProtoMessage() {} 229 | 230 | func (x *Event_EmailWasUpdated) ProtoReflect() protoreflect.Message { 231 | mi := &file_user_v1_event_proto_msgTypes[2] 232 | if protoimpl.UnsafeEnabled && x != nil { 233 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 234 | if ms.LoadMessageInfo() == nil { 235 | ms.StoreMessageInfo(mi) 236 | } 237 | return ms 238 | } 239 | return mi.MessageOf(x) 240 | } 241 | 242 | // Deprecated: Use Event_EmailWasUpdated.ProtoReflect.Descriptor instead. 243 | func (*Event_EmailWasUpdated) Descriptor() ([]byte, []int) { 244 | return file_user_v1_event_proto_rawDescGZIP(), []int{0, 1} 245 | } 246 | 247 | func (x *Event_EmailWasUpdated) GetEmail() string { 248 | if x != nil { 249 | return x.Email 250 | } 251 | return "" 252 | } 253 | 254 | var File_user_v1_event_proto protoreflect.FileDescriptor 255 | 256 | var file_user_v1_event_proto_rawDesc = []byte{ 257 | 0x0a, 0x13, 0x75, 0x73, 0x65, 0x72, 0x2f, 0x76, 0x31, 0x2f, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x2e, 258 | 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x07, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x1a, 0x1f, 259 | 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 260 | 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 261 | 0x16, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x74, 0x79, 0x70, 0x65, 0x2f, 0x64, 0x61, 0x74, 262 | 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xa4, 0x03, 0x0a, 0x05, 0x45, 0x76, 0x65, 0x6e, 263 | 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 264 | 0x64, 0x12, 0x3b, 0x0a, 0x0b, 0x72, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x5f, 0x74, 0x69, 0x6d, 0x65, 265 | 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 266 | 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 267 | 0x6d, 0x70, 0x52, 0x0a, 0x72, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x69, 0x6d, 0x65, 0x12, 0x3c, 268 | 0x0a, 0x0b, 0x77, 0x61, 0x73, 0x5f, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x18, 0x03, 0x20, 269 | 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x45, 0x76, 270 | 0x65, 0x6e, 0x74, 0x2e, 0x57, 0x61, 0x73, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x48, 0x00, 271 | 0x52, 0x0a, 0x77, 0x61, 0x73, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x12, 0x4c, 0x0a, 0x11, 272 | 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x5f, 0x77, 0x61, 0x73, 0x5f, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 273 | 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 274 | 0x31, 0x2e, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x57, 0x61, 0x73, 275 | 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x48, 0x00, 0x52, 0x0f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 276 | 0x57, 0x61, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x1a, 0x90, 0x01, 0x0a, 0x0a, 0x57, 277 | 0x61, 0x73, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x66, 0x69, 0x72, 278 | 0x73, 0x74, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x66, 279 | 0x69, 0x72, 0x73, 0x74, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1b, 0x0a, 0x09, 0x6c, 0x61, 0x73, 0x74, 280 | 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x6c, 0x61, 0x73, 281 | 0x74, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x30, 0x0a, 0x0a, 0x62, 0x69, 0x72, 0x74, 0x68, 0x5f, 0x64, 282 | 0x61, 0x74, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 283 | 0x6c, 0x65, 0x2e, 0x74, 0x79, 0x70, 0x65, 0x2e, 0x44, 0x61, 0x74, 0x65, 0x52, 0x09, 0x62, 0x69, 284 | 0x72, 0x74, 0x68, 0x44, 0x61, 0x74, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 285 | 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x1a, 0x27, 0x0a, 286 | 0x0f, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x57, 0x61, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 287 | 0x12, 0x14, 0x0a, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 288 | 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x42, 0x06, 0x0a, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x42, 0xa0, 289 | 0x01, 0x0a, 0x0b, 0x63, 0x6f, 0x6d, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x42, 0x0a, 290 | 0x45, 0x76, 0x65, 0x6e, 0x74, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x50, 0x01, 0x5a, 0x48, 0x67, 0x69, 291 | 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x67, 0x65, 0x74, 0x2d, 0x65, 0x76, 0x65, 292 | 0x6e, 0x74, 0x75, 0x61, 0x6c, 0x6c, 0x79, 0x2f, 0x67, 0x6f, 0x2d, 0x65, 0x76, 0x65, 0x6e, 0x74, 293 | 0x75, 0x61, 0x6c, 0x6c, 0x79, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x75, 294 | 0x73, 0x65, 0x72, 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x75, 0x73, 0x65, 0x72, 0x2f, 0x76, 0x31, 0x3b, 295 | 0x75, 0x73, 0x65, 0x72, 0x76, 0x31, 0xa2, 0x02, 0x03, 0x55, 0x58, 0x58, 0xaa, 0x02, 0x07, 0x55, 296 | 0x73, 0x65, 0x72, 0x2e, 0x56, 0x31, 0xca, 0x02, 0x07, 0x55, 0x73, 0x65, 0x72, 0x5c, 0x56, 0x31, 297 | 0xe2, 0x02, 0x13, 0x55, 0x73, 0x65, 0x72, 0x5c, 0x56, 0x31, 0x5c, 0x47, 0x50, 0x42, 0x4d, 0x65, 298 | 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0xea, 0x02, 0x08, 0x55, 0x73, 0x65, 0x72, 0x3a, 0x3a, 0x56, 299 | 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, 300 | } 301 | 302 | var ( 303 | file_user_v1_event_proto_rawDescOnce sync.Once 304 | file_user_v1_event_proto_rawDescData = file_user_v1_event_proto_rawDesc 305 | ) 306 | 307 | func file_user_v1_event_proto_rawDescGZIP() []byte { 308 | file_user_v1_event_proto_rawDescOnce.Do(func() { 309 | file_user_v1_event_proto_rawDescData = protoimpl.X.CompressGZIP(file_user_v1_event_proto_rawDescData) 310 | }) 311 | return file_user_v1_event_proto_rawDescData 312 | } 313 | 314 | var file_user_v1_event_proto_msgTypes = make([]protoimpl.MessageInfo, 3) 315 | var file_user_v1_event_proto_goTypes = []interface{}{ 316 | (*Event)(nil), // 0: user.v1.Event 317 | (*Event_WasCreated)(nil), // 1: user.v1.Event.WasCreated 318 | (*Event_EmailWasUpdated)(nil), // 2: user.v1.Event.EmailWasUpdated 319 | (*timestamppb.Timestamp)(nil), // 3: google.protobuf.Timestamp 320 | (*date.Date)(nil), // 4: google.type.Date 321 | } 322 | var file_user_v1_event_proto_depIdxs = []int32{ 323 | 3, // 0: user.v1.Event.record_time:type_name -> google.protobuf.Timestamp 324 | 1, // 1: user.v1.Event.was_created:type_name -> user.v1.Event.WasCreated 325 | 2, // 2: user.v1.Event.email_was_updated:type_name -> user.v1.Event.EmailWasUpdated 326 | 4, // 3: user.v1.Event.WasCreated.birth_date:type_name -> google.type.Date 327 | 4, // [4:4] is the sub-list for method output_type 328 | 4, // [4:4] is the sub-list for method input_type 329 | 4, // [4:4] is the sub-list for extension type_name 330 | 4, // [4:4] is the sub-list for extension extendee 331 | 0, // [0:4] is the sub-list for field type_name 332 | } 333 | 334 | func init() { file_user_v1_event_proto_init() } 335 | func file_user_v1_event_proto_init() { 336 | if File_user_v1_event_proto != nil { 337 | return 338 | } 339 | if !protoimpl.UnsafeEnabled { 340 | file_user_v1_event_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { 341 | switch v := v.(*Event); i { 342 | case 0: 343 | return &v.state 344 | case 1: 345 | return &v.sizeCache 346 | case 2: 347 | return &v.unknownFields 348 | default: 349 | return nil 350 | } 351 | } 352 | file_user_v1_event_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { 353 | switch v := v.(*Event_WasCreated); i { 354 | case 0: 355 | return &v.state 356 | case 1: 357 | return &v.sizeCache 358 | case 2: 359 | return &v.unknownFields 360 | default: 361 | return nil 362 | } 363 | } 364 | file_user_v1_event_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { 365 | switch v := v.(*Event_EmailWasUpdated); i { 366 | case 0: 367 | return &v.state 368 | case 1: 369 | return &v.sizeCache 370 | case 2: 371 | return &v.unknownFields 372 | default: 373 | return nil 374 | } 375 | } 376 | } 377 | file_user_v1_event_proto_msgTypes[0].OneofWrappers = []interface{}{ 378 | (*Event_WasCreated_)(nil), 379 | (*Event_EmailWasUpdated_)(nil), 380 | } 381 | type x struct{} 382 | out := protoimpl.TypeBuilder{ 383 | File: protoimpl.DescBuilder{ 384 | GoPackagePath: reflect.TypeOf(x{}).PkgPath(), 385 | RawDescriptor: file_user_v1_event_proto_rawDesc, 386 | NumEnums: 0, 387 | NumMessages: 3, 388 | NumExtensions: 0, 389 | NumServices: 0, 390 | }, 391 | GoTypes: file_user_v1_event_proto_goTypes, 392 | DependencyIndexes: file_user_v1_event_proto_depIdxs, 393 | MessageInfos: file_user_v1_event_proto_msgTypes, 394 | }.Build() 395 | File_user_v1_event_proto = out.File 396 | file_user_v1_event_proto_rawDesc = nil 397 | file_user_v1_event_proto_goTypes = nil 398 | file_user_v1_event_proto_depIdxs = nil 399 | } 400 | -------------------------------------------------------------------------------- /internal/user/gen/user/v1/user.pb.go: -------------------------------------------------------------------------------- 1 | // Code generated by protoc-gen-go. DO NOT EDIT. 2 | // versions: 3 | // protoc-gen-go v1.31.0 4 | // protoc (unknown) 5 | // source: user/v1/user.proto 6 | 7 | package userv1 8 | 9 | import ( 10 | date "google.golang.org/genproto/googleapis/type/date" 11 | protoreflect "google.golang.org/protobuf/reflect/protoreflect" 12 | protoimpl "google.golang.org/protobuf/runtime/protoimpl" 13 | reflect "reflect" 14 | sync "sync" 15 | ) 16 | 17 | const ( 18 | // Verify that this generated code is sufficiently up-to-date. 19 | _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) 20 | // Verify that runtime/protoimpl is sufficiently up-to-date. 21 | _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) 22 | ) 23 | 24 | // Represents a User resource. 25 | type User struct { 26 | state protoimpl.MessageState 27 | sizeCache protoimpl.SizeCache 28 | unknownFields protoimpl.UnknownFields 29 | 30 | // The User's primary unique identifier. 31 | Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` 32 | // The User's first name. 33 | FirstName string `protobuf:"bytes,2,opt,name=first_name,json=firstName,proto3" json:"first_name,omitempty"` 34 | // The User's last name. 35 | LastName string `protobuf:"bytes,3,opt,name=last_name,json=lastName,proto3" json:"last_name,omitempty"` 36 | // The User's birth date. 37 | BirthDate *date.Date `protobuf:"bytes,4,opt,name=birth_date,json=birthDate,proto3" json:"birth_date,omitempty"` 38 | // The User's email. 39 | Email string `protobuf:"bytes,5,opt,name=email,proto3" json:"email,omitempty"` 40 | } 41 | 42 | func (x *User) Reset() { 43 | *x = User{} 44 | if protoimpl.UnsafeEnabled { 45 | mi := &file_user_v1_user_proto_msgTypes[0] 46 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 47 | ms.StoreMessageInfo(mi) 48 | } 49 | } 50 | 51 | func (x *User) String() string { 52 | return protoimpl.X.MessageStringOf(x) 53 | } 54 | 55 | func (*User) ProtoMessage() {} 56 | 57 | func (x *User) ProtoReflect() protoreflect.Message { 58 | mi := &file_user_v1_user_proto_msgTypes[0] 59 | if protoimpl.UnsafeEnabled && x != nil { 60 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 61 | if ms.LoadMessageInfo() == nil { 62 | ms.StoreMessageInfo(mi) 63 | } 64 | return ms 65 | } 66 | return mi.MessageOf(x) 67 | } 68 | 69 | // Deprecated: Use User.ProtoReflect.Descriptor instead. 70 | func (*User) Descriptor() ([]byte, []int) { 71 | return file_user_v1_user_proto_rawDescGZIP(), []int{0} 72 | } 73 | 74 | func (x *User) GetId() string { 75 | if x != nil { 76 | return x.Id 77 | } 78 | return "" 79 | } 80 | 81 | func (x *User) GetFirstName() string { 82 | if x != nil { 83 | return x.FirstName 84 | } 85 | return "" 86 | } 87 | 88 | func (x *User) GetLastName() string { 89 | if x != nil { 90 | return x.LastName 91 | } 92 | return "" 93 | } 94 | 95 | func (x *User) GetBirthDate() *date.Date { 96 | if x != nil { 97 | return x.BirthDate 98 | } 99 | return nil 100 | } 101 | 102 | func (x *User) GetEmail() string { 103 | if x != nil { 104 | return x.Email 105 | } 106 | return "" 107 | } 108 | 109 | var File_user_v1_user_proto protoreflect.FileDescriptor 110 | 111 | var file_user_v1_user_proto_rawDesc = []byte{ 112 | 0x0a, 0x12, 0x75, 0x73, 0x65, 0x72, 0x2f, 0x76, 0x31, 0x2f, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x70, 113 | 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x07, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x1a, 0x16, 0x67, 114 | 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x74, 0x79, 0x70, 0x65, 0x2f, 0x64, 0x61, 0x74, 0x65, 0x2e, 115 | 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x9a, 0x01, 0x0a, 0x04, 0x55, 0x73, 0x65, 0x72, 0x12, 0x0e, 116 | 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x1d, 117 | 0x0a, 0x0a, 0x66, 0x69, 0x72, 0x73, 0x74, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 118 | 0x28, 0x09, 0x52, 0x09, 0x66, 0x69, 0x72, 0x73, 0x74, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1b, 0x0a, 119 | 0x09, 0x6c, 0x61, 0x73, 0x74, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 120 | 0x52, 0x08, 0x6c, 0x61, 0x73, 0x74, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x30, 0x0a, 0x0a, 0x62, 0x69, 121 | 0x72, 0x74, 0x68, 0x5f, 0x64, 0x61, 0x74, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x11, 122 | 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x74, 0x79, 0x70, 0x65, 0x2e, 0x44, 0x61, 0x74, 123 | 0x65, 0x52, 0x09, 0x62, 0x69, 0x72, 0x74, 0x68, 0x44, 0x61, 0x74, 0x65, 0x12, 0x14, 0x0a, 0x05, 124 | 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x6d, 0x61, 125 | 0x69, 0x6c, 0x42, 0x9f, 0x01, 0x0a, 0x0b, 0x63, 0x6f, 0x6d, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 126 | 0x76, 0x31, 0x42, 0x09, 0x55, 0x73, 0x65, 0x72, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x50, 0x01, 0x5a, 127 | 0x48, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x67, 0x65, 0x74, 0x2d, 128 | 0x65, 0x76, 0x65, 0x6e, 0x74, 0x75, 0x61, 0x6c, 0x6c, 0x79, 0x2f, 0x67, 0x6f, 0x2d, 0x65, 0x76, 129 | 0x65, 0x6e, 0x74, 0x75, 0x61, 0x6c, 0x6c, 0x79, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 130 | 0x6c, 0x2f, 0x75, 0x73, 0x65, 0x72, 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x75, 0x73, 0x65, 0x72, 0x2f, 131 | 0x76, 0x31, 0x3b, 0x75, 0x73, 0x65, 0x72, 0x76, 0x31, 0xa2, 0x02, 0x03, 0x55, 0x58, 0x58, 0xaa, 132 | 0x02, 0x07, 0x55, 0x73, 0x65, 0x72, 0x2e, 0x56, 0x31, 0xca, 0x02, 0x07, 0x55, 0x73, 0x65, 0x72, 133 | 0x5c, 0x56, 0x31, 0xe2, 0x02, 0x13, 0x55, 0x73, 0x65, 0x72, 0x5c, 0x56, 0x31, 0x5c, 0x47, 0x50, 134 | 0x42, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0xea, 0x02, 0x08, 0x55, 0x73, 0x65, 0x72, 135 | 0x3a, 0x3a, 0x56, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, 136 | } 137 | 138 | var ( 139 | file_user_v1_user_proto_rawDescOnce sync.Once 140 | file_user_v1_user_proto_rawDescData = file_user_v1_user_proto_rawDesc 141 | ) 142 | 143 | func file_user_v1_user_proto_rawDescGZIP() []byte { 144 | file_user_v1_user_proto_rawDescOnce.Do(func() { 145 | file_user_v1_user_proto_rawDescData = protoimpl.X.CompressGZIP(file_user_v1_user_proto_rawDescData) 146 | }) 147 | return file_user_v1_user_proto_rawDescData 148 | } 149 | 150 | var file_user_v1_user_proto_msgTypes = make([]protoimpl.MessageInfo, 1) 151 | var file_user_v1_user_proto_goTypes = []interface{}{ 152 | (*User)(nil), // 0: user.v1.User 153 | (*date.Date)(nil), // 1: google.type.Date 154 | } 155 | var file_user_v1_user_proto_depIdxs = []int32{ 156 | 1, // 0: user.v1.User.birth_date:type_name -> google.type.Date 157 | 1, // [1:1] is the sub-list for method output_type 158 | 1, // [1:1] is the sub-list for method input_type 159 | 1, // [1:1] is the sub-list for extension type_name 160 | 1, // [1:1] is the sub-list for extension extendee 161 | 0, // [0:1] is the sub-list for field type_name 162 | } 163 | 164 | func init() { file_user_v1_user_proto_init() } 165 | func file_user_v1_user_proto_init() { 166 | if File_user_v1_user_proto != nil { 167 | return 168 | } 169 | if !protoimpl.UnsafeEnabled { 170 | file_user_v1_user_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { 171 | switch v := v.(*User); i { 172 | case 0: 173 | return &v.state 174 | case 1: 175 | return &v.sizeCache 176 | case 2: 177 | return &v.unknownFields 178 | default: 179 | return nil 180 | } 181 | } 182 | } 183 | type x struct{} 184 | out := protoimpl.TypeBuilder{ 185 | File: protoimpl.DescBuilder{ 186 | GoPackagePath: reflect.TypeOf(x{}).PkgPath(), 187 | RawDescriptor: file_user_v1_user_proto_rawDesc, 188 | NumEnums: 0, 189 | NumMessages: 1, 190 | NumExtensions: 0, 191 | NumServices: 0, 192 | }, 193 | GoTypes: file_user_v1_user_proto_goTypes, 194 | DependencyIndexes: file_user_v1_user_proto_depIdxs, 195 | MessageInfos: file_user_v1_user_proto_msgTypes, 196 | }.Build() 197 | File_user_v1_user_proto = out.File 198 | file_user_v1_user_proto_rawDesc = nil 199 | file_user_v1_user_proto_goTypes = nil 200 | file_user_v1_user_proto_depIdxs = nil 201 | } 202 | -------------------------------------------------------------------------------- /internal/user/proto/buf.lock: -------------------------------------------------------------------------------- 1 | # Generated by buf. DO NOT EDIT. 2 | version: v1 3 | deps: 4 | - remote: buf.build 5 | owner: googleapis 6 | repository: googleapis 7 | commit: ee48893a270147348e3edc6c1a03de0e 8 | digest: shake256:a35b0576a2b55dad72747e786af05c03539c2b96be236c9de39fe10d551931ac252eb68445c0cef6bbd27fa20e8c26eee5b8a9fe9c2fde6f63a03e18f8cf980d 9 | -------------------------------------------------------------------------------- /internal/user/proto/buf.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | version: v1 3 | deps: 4 | - buf.build/googleapis/googleapis 5 | breaking: 6 | use: 7 | - FILE 8 | lint: 9 | rpc_allow_google_protobuf_empty_responses: true 10 | use: 11 | - DEFAULT 12 | - COMMENTS 13 | - UNARY_RPC 14 | - PACKAGE_NO_IMPORT_CYCLE 15 | -------------------------------------------------------------------------------- /internal/user/proto/user/v1/event.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package user.v1; 4 | 5 | import "google/protobuf/timestamp.proto"; 6 | import "google/type/date.proto"; 7 | 8 | // Represents a domain event for a specific User. 9 | message Event { 10 | // Specified that a new User was created. 11 | message WasCreated { 12 | // The User's first name. 13 | string first_name = 1; 14 | // The User's last name. 15 | string last_name = 2; 16 | // The User's birth name. 17 | google.type.Date birth_date = 3; 18 | // The User's email. 19 | string email = 4; 20 | } 21 | 22 | // Specifies that the email of an existing User was updated. 23 | message EmailWasUpdated { 24 | // The new User email. 25 | string email = 1; 26 | } 27 | 28 | // The primary identifier for the User. 29 | string id = 1; 30 | 31 | // The timestamp of when the domain event has been recorded. 32 | google.protobuf.Timestamp record_time = 2; 33 | 34 | // All the possible kind of User domain event supported. 35 | oneof kind { 36 | // When a new User has been created. 37 | WasCreated was_created = 3; 38 | // When a User email has been updated. 39 | EmailWasUpdated email_was_updated = 4; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /internal/user/proto/user/v1/user.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package user.v1; 4 | 5 | import "google/type/date.proto"; 6 | 7 | // Represents a User resource. 8 | message User { 9 | // The User's primary unique identifier. 10 | string id = 1; 11 | 12 | // The User's first name. 13 | string first_name = 2; 14 | 15 | // The User's last name. 16 | string last_name = 3; 17 | 18 | // The User's birth date. 19 | google.type.Date birth_date = 4; 20 | 21 | // The User's email. 22 | string email = 5; 23 | } 24 | -------------------------------------------------------------------------------- /internal/user/serde.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/google/uuid" 8 | "google.golang.org/genproto/googleapis/type/date" 9 | "google.golang.org/protobuf/types/known/timestamppb" 10 | 11 | userv1 "github.com/get-eventually/go-eventually/internal/user/gen/user/v1" 12 | "github.com/get-eventually/go-eventually/message" 13 | "github.com/get-eventually/go-eventually/serde" 14 | ) 15 | 16 | func timeToDate(t time.Time) *date.Date { 17 | //nolint:gosec // These surely won't overflow. 18 | return &date.Date{ 19 | Year: int32(t.Year()), 20 | Month: int32(t.Month()), 21 | Day: int32(t.Day()), 22 | } 23 | } 24 | 25 | func dateToTime(d *date.Date) time.Time { 26 | return time.Date( 27 | int(d.Year), time.Month(d.Month), int(d.Day), 28 | 0, 0, 0, 0, time.UTC, 29 | ) 30 | } 31 | 32 | // ProtoSerde is the serde.Serde implementation for a User to map 33 | // to its Protobuf type, defined in the proto/ folder. 34 | var ProtoSerde = serde.Fused[*User, *userv1.User]{ 35 | Serializer: serde.SerializerFunc[*User, *userv1.User](protoSerializer), 36 | Deserializer: serde.DeserializerFunc[*User, *userv1.User](protoDeserializer), 37 | } 38 | 39 | func protoSerializer(user *User) (*userv1.User, error) { 40 | return &userv1.User{ 41 | Id: user.id.String(), 42 | FirstName: user.firstName, 43 | LastName: user.lastName, 44 | BirthDate: timeToDate(user.birthDate), 45 | Email: user.email, 46 | }, nil 47 | } 48 | 49 | func protoDeserializer(src *userv1.User) (*User, error) { 50 | id, err := uuid.Parse(src.Id) 51 | if err != nil { 52 | return nil, fmt.Errorf("user.protoDeserialize: failed to deserialize user id, %w", err) 53 | } 54 | 55 | user := &User{ //nolint:exhaustruct // Other fields will be set by eventually. 56 | id: id, 57 | firstName: src.FirstName, 58 | lastName: src.LastName, 59 | birthDate: dateToTime(src.BirthDate), 60 | email: src.Email, 61 | } 62 | 63 | return user, nil 64 | } 65 | 66 | // EventProtoSerde is the serde.Serde implementation for User domain events 67 | // to map to their Protobuf type, defined in the proto/ folder. 68 | var EventProtoSerde = serde.Fused[message.Message, *userv1.Event]{ 69 | Serializer: serde.SerializerFunc[message.Message, *userv1.Event](protoEventSerializer), 70 | Deserializer: serde.DeserializerFunc[message.Message, *userv1.Event](protoEventDeserializer), 71 | } 72 | 73 | func protoEventSerializer(evt message.Message) (*userv1.Event, error) { 74 | userEvent, ok := evt.(*Event) 75 | if !ok { 76 | return nil, fmt.Errorf("user.protoEventSerializer: unexpected event type, %T", evt) 77 | } 78 | 79 | switch kind := userEvent.Kind.(type) { 80 | case *WasCreated: 81 | return &userv1.Event{ 82 | Id: userEvent.ID.String(), 83 | RecordTime: timestamppb.New(userEvent.RecordTime), 84 | Kind: &userv1.Event_WasCreated_{ 85 | WasCreated: &userv1.Event_WasCreated{ 86 | FirstName: kind.FirstName, 87 | LastName: kind.LastName, 88 | BirthDate: timeToDate(kind.BirthDate), 89 | Email: kind.Email, 90 | }, 91 | }, 92 | }, nil 93 | case *EmailWasUpdated: 94 | return &userv1.Event{ 95 | Id: userEvent.ID.String(), 96 | RecordTime: timestamppb.New(userEvent.RecordTime), 97 | Kind: &userv1.Event_EmailWasUpdated_{ 98 | EmailWasUpdated: &userv1.Event_EmailWasUpdated{ 99 | Email: kind.Email, 100 | }, 101 | }, 102 | }, nil 103 | default: 104 | return nil, fmt.Errorf("user.protoEventSerializer: unexpected event kind type, %T", kind) 105 | } 106 | } 107 | 108 | func protoEventDeserializer(evt *userv1.Event) (message.Message, error) { 109 | id, err := uuid.Parse(evt.Id) 110 | if err != nil { 111 | return nil, fmt.Errorf("user.protoEventDeserializer: failed to parse id, %w", err) 112 | } 113 | 114 | switch t := evt.Kind.(type) { 115 | case *userv1.Event_WasCreated_: 116 | return &Event{ 117 | ID: id, 118 | RecordTime: evt.RecordTime.AsTime(), 119 | Kind: &WasCreated{ 120 | FirstName: t.WasCreated.FirstName, 121 | LastName: t.WasCreated.LastName, 122 | BirthDate: dateToTime(t.WasCreated.BirthDate), 123 | Email: t.WasCreated.Email, 124 | }, 125 | }, nil 126 | 127 | case *userv1.Event_EmailWasUpdated_: 128 | return &Event{ 129 | ID: id, 130 | RecordTime: evt.RecordTime.AsTime(), 131 | Kind: &EmailWasUpdated{ 132 | Email: t.EmailWasUpdated.Email, 133 | }, 134 | }, nil 135 | 136 | default: 137 | return nil, fmt.Errorf("user.protoEventDeserializer: invalid event type, %T", evt) 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /internal/user/suite_aggregate_repository.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | "github.com/google/uuid" 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | 12 | "github.com/get-eventually/go-eventually/aggregate" 13 | "github.com/get-eventually/go-eventually/message" 14 | "github.com/get-eventually/go-eventually/version" 15 | ) 16 | 17 | // AggregateRepositorySuite returns an executable testing suite running on the 18 | // agfgregate.Repository value provided in input. 19 | // 20 | // The aggregate.Repository value requested should comply with the given signature. 21 | // 22 | // Package user of this module exposes a Protobuf-based serde, which can be useful 23 | // to test serialization and deserialization of data to the target repository implementation. 24 | func AggregateRepositorySuite(repository aggregate.Repository[uuid.UUID, *User]) func(t *testing.T) { //nolint:funlen // It's a test suite. 25 | return func(t *testing.T) { 26 | ctx := context.Background() 27 | now := time.Now() 28 | 29 | t.Run("it can load and save aggregates from the database", func(t *testing.T) { 30 | var ( 31 | id = uuid.New() 32 | firstName = "John" 33 | lastName = "Doe" 34 | email = "john@doe.com" 35 | birthDate = time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC) 36 | ) 37 | 38 | _, err := repository.Get(ctx, id) 39 | if !assert.ErrorIs(t, err, aggregate.ErrRootNotFound) { 40 | return 41 | } 42 | 43 | usr, err := Create(id, firstName, lastName, email, birthDate, now) 44 | if !assert.NoError(t, err) { 45 | return 46 | } 47 | 48 | if err := repository.Save(ctx, usr); !assert.NoError(t, err) { 49 | return 50 | } 51 | 52 | got, err := repository.Get(ctx, id) 53 | assert.NoError(t, err) 54 | assert.Equal(t, usr, got) 55 | }) 56 | 57 | t.Run("optimistic locking of aggregates is also working fine", func(t *testing.T) { 58 | var ( 59 | id = uuid.New() 60 | firstName = "John" 61 | lastName = "Doe" 62 | email = "john@doe.com" 63 | birthDate = time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC) 64 | ) 65 | 66 | user, err := Create(id, firstName, lastName, email, birthDate, now) 67 | require.NoError(t, err) 68 | 69 | newEmail := "johndoe@gmail.com" 70 | require.NoError(t, user.UpdateEmail(newEmail, now, message.Metadata{ 71 | "Testing-Metadata-Time": time.Now().Format(time.RFC3339), 72 | })) 73 | 74 | if err := repository.Save(ctx, user); !assert.NoError(t, err) { 75 | return 76 | } 77 | 78 | // Try to create a new User instance, but stop at Create. 79 | outdatedUsr, err := Create(id, firstName, lastName, email, birthDate, now) 80 | require.NoError(t, err) 81 | 82 | err = repository.Save(ctx, outdatedUsr) 83 | 84 | expectedErr := version.ConflictError{ 85 | Expected: 0, 86 | Actual: 2, //nolint:mnd // False positive. 87 | } 88 | 89 | var conflictErr version.ConflictError 90 | 91 | assert.ErrorAs(t, err, &conflictErr) 92 | assert.Equal(t, expectedErr, conflictErr) 93 | }) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /internal/user/suite_event_store.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | "github.com/google/uuid" 9 | "github.com/stretchr/testify/require" 10 | 11 | "github.com/get-eventually/go-eventually/aggregate" 12 | "github.com/get-eventually/go-eventually/event" 13 | "github.com/get-eventually/go-eventually/version" 14 | ) 15 | 16 | // EventStoreSuite returns an executable testing suite running on the event.Store 17 | // value provided in input. 18 | func EventStoreSuite(eventStore event.Store) func(t *testing.T) { 19 | return func(t *testing.T) { 20 | ctx := context.Background() 21 | now := time.Now() 22 | 23 | // Testing the Event-sourced repository implementation, which indirectly 24 | // tests the Event Store instance. 25 | AggregateRepositorySuite(aggregate.NewEventSourcedRepository(eventStore, Type))(t) 26 | 27 | t.Run("append works when used with version.CheckAny", func(t *testing.T) { 28 | id := uuid.New() 29 | 30 | usr, err := Create(id, "Dani", "Ross", "dani@ross.com", now, now) 31 | require.NoError(t, err) 32 | 33 | require.NoError(t, usr.UpdateEmail("dani.ross@mail.com", now, nil)) 34 | 35 | eventsToCommit := usr.FlushRecordedEvents() 36 | expectedVersion := version.Version(len(eventsToCommit)) //nolint:gosec // This should not overflow. 37 | 38 | newVersion, err := eventStore.Append( 39 | ctx, 40 | event.StreamID(id.String()), 41 | version.Any, 42 | eventsToCommit..., 43 | ) 44 | 45 | require.NoError(t, err) 46 | require.Equal(t, expectedVersion, newVersion) 47 | 48 | // Now let's update the User event stream once more. 49 | 50 | require.NoError(t, usr.UpdateEmail("daniross123@gmail.com", now, nil)) 51 | 52 | newEventsToCommit := usr.FlushRecordedEvents() 53 | expectedVersion += version.Version(len(newEventsToCommit)) //nolint:gosec // This should not overflow. 54 | 55 | newVersion, err = eventStore.Append( 56 | ctx, 57 | event.StreamID(id.String()), 58 | version.Any, 59 | newEventsToCommit..., 60 | ) 61 | 62 | require.NoError(t, err) 63 | require.Equal(t, expectedVersion, newVersion) 64 | }) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /internal/user/user_by_email.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "sync" 8 | "time" 9 | 10 | "github.com/google/uuid" 11 | 12 | "github.com/get-eventually/go-eventually/event" 13 | "github.com/get-eventually/go-eventually/query" 14 | "github.com/get-eventually/go-eventually/version" 15 | ) 16 | 17 | // View is a public-facing representation of a User entity. 18 | // Can be obtained through a Query handler. 19 | type View struct { 20 | ID uuid.UUID 21 | Email string 22 | FirstName, LastName string 23 | BirthDate time.Time 24 | 25 | Version version.Version // NOTE: used to avoid re-processing of already-processed events. 26 | } 27 | 28 | // ErrNotFound is returned by a Query when a specific User has not been found. 29 | var ErrNotFound = errors.New("user: not found") 30 | 31 | var ( 32 | _ query.Query = GetByEmail("test@email.com") 33 | _ query.ProcessorHandler[GetByEmail, View] = new(GetByEmailHandler) 34 | ) 35 | 36 | // GetByEmail is a Domain Query that can be used to fetch a specific User given its email. 37 | type GetByEmail string 38 | 39 | // Name implements query.Query. 40 | func (GetByEmail) Name() string { return "GetUserByEmail" } 41 | 42 | // GetByEmailHandler is a stateful Query Handler that maintains a list of Users 43 | // indexed by their email. 44 | // 45 | // It can be used to answer GetByEmail queries. 46 | // 47 | // GetByEmailHandler is thread-safe. 48 | type GetByEmailHandler struct { 49 | mx sync.RWMutex 50 | data map[string]View 51 | idToEmail map[uuid.UUID]string 52 | } 53 | 54 | // NewGetByEmailHandler creates a new GetByEmailHandler instance. 55 | func NewGetByEmailHandler() *GetByEmailHandler { 56 | handler := new(GetByEmailHandler) 57 | handler.data = make(map[string]View) 58 | handler.idToEmail = make(map[uuid.UUID]string) 59 | 60 | return handler 61 | } 62 | 63 | // Handle implements query.Handler. 64 | func (handler *GetByEmailHandler) Handle(_ context.Context, q query.Envelope[GetByEmail]) (View, error) { 65 | handler.mx.RLock() 66 | defer handler.mx.RUnlock() 67 | 68 | user, ok := handler.data[string(q.Message)] 69 | if !ok { 70 | return View{}, fmt.Errorf("user.GetByEmailHandler: failed to get User by email, %w", ErrNotFound) 71 | } 72 | 73 | return user, nil 74 | } 75 | 76 | // Process implements event.Processor. 77 | func (handler *GetByEmailHandler) Process(_ context.Context, evt event.Persisted) error { 78 | handler.mx.Lock() 79 | defer handler.mx.Unlock() 80 | 81 | userEvent, ok := evt.Message.(*Event) 82 | if !ok { 83 | return fmt.Errorf("user.GetByEmailHandler: unexpected event type, %T", evt.Message) 84 | } 85 | 86 | switch kind := userEvent.Kind.(type) { 87 | case *WasCreated: 88 | handler.idToEmail[userEvent.ID] = kind.Email 89 | handler.data[kind.Email] = View{ 90 | ID: userEvent.ID, 91 | Email: kind.Email, 92 | FirstName: kind.FirstName, 93 | LastName: kind.LastName, 94 | BirthDate: kind.BirthDate, 95 | Version: evt.Version, 96 | } 97 | 98 | case *EmailWasUpdated: 99 | previousEmail, ok := handler.idToEmail[userEvent.ID] 100 | if !ok { 101 | return fmt.Errorf("user.GetByEmailHandler: expected id to have been registered, none found") 102 | } 103 | 104 | view, ok := handler.data[previousEmail] 105 | if !ok { 106 | return fmt.Errorf("user.GetByEmailHandler: expected view to be registered, none found") 107 | } 108 | 109 | if view.Version >= evt.Version { 110 | return nil 111 | } 112 | 113 | view.Email = kind.Email 114 | handler.idToEmail[userEvent.ID] = view.Email 115 | handler.data[view.Email] = view 116 | 117 | default: 118 | return fmt.Errorf("user.GetByEmailHandler: unexpected User event kind, %T", kind) 119 | } 120 | 121 | return nil 122 | } 123 | -------------------------------------------------------------------------------- /message/message.go: -------------------------------------------------------------------------------- 1 | // Package message exposes the generic Message type, used to represent 2 | // a message in a system (e.g. Event, Command, etc.). 3 | package message 4 | 5 | // Message is a Message payload. 6 | // 7 | // Each payload should have a unique name identifier, that can be used 8 | // to uniquely route a message to its type. 9 | type Message interface { 10 | Name() string 11 | } 12 | 13 | // Metadata contains some data related to a Message that are not functional 14 | // for the Message itself, but instead functioning as supporting information 15 | // to provide additional context. 16 | type Metadata map[string]string 17 | 18 | // With returns a new Metadata reference holding the value addressed using 19 | // the specified key. 20 | func (m Metadata) With(key, value string) Metadata { 21 | if m == nil { 22 | m = make(Metadata) 23 | } 24 | 25 | m[key] = value 26 | 27 | return m 28 | } 29 | 30 | // Merge merges the other Metadata provided in input with the current map. 31 | // Returns a pointer to the extended metadata map. 32 | func (m Metadata) Merge(other Metadata) Metadata { 33 | if m == nil { 34 | return other 35 | } 36 | 37 | for k, v := range other { 38 | m[k] = v 39 | } 40 | 41 | return m 42 | } 43 | 44 | // GenericEnvelope is an Envelope type that can be used when the concrete 45 | // Message type in the Envelope is not of interest. 46 | type GenericEnvelope Envelope[Message] 47 | 48 | // Envelope bundles a Message to be exchanged with optional Metadata support. 49 | type Envelope[T Message] struct { 50 | Message T 51 | Metadata Metadata 52 | } 53 | 54 | // ToGenericEnvelope maps the Envelope instance into a GenericEnvelope one. 55 | func (e Envelope[T]) ToGenericEnvelope() GenericEnvelope { 56 | return GenericEnvelope{ 57 | Message: e.Message, 58 | Metadata: e.Metadata, 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /opentelemetry/config.go: -------------------------------------------------------------------------------- 1 | package opentelemetry 2 | 3 | import ( 4 | "go.opentelemetry.io/otel" 5 | "go.opentelemetry.io/otel/metric" 6 | "go.opentelemetry.io/otel/trace" 7 | ) 8 | 9 | const instrumentationName = "github.com/get-eventually/go-eventually/opentelemetry" 10 | 11 | type config struct { 12 | MeterProvider metric.MeterProvider 13 | TracerProvider trace.TracerProvider 14 | } 15 | 16 | func (c config) meter() metric.Meter { 17 | return c.MeterProvider.Meter(instrumentationName) 18 | } 19 | 20 | func (c config) tracer() trace.Tracer { 21 | return c.TracerProvider.Tracer(instrumentationName) 22 | } 23 | 24 | // Option specifies instrumentation configuration options. 25 | type Option interface { 26 | apply(*config) 27 | } 28 | 29 | type meterProviderOption struct{ metric.MeterProvider } 30 | 31 | func (o meterProviderOption) apply(c *config) { 32 | c.MeterProvider = o.MeterProvider 33 | } 34 | 35 | // WithMeterProvider specifies the metric.MeterProvider instance to use for the instrumentation. 36 | // By default, the global metric.MeterProvider is used. 37 | func WithMeterProvider(provider metric.MeterProvider) Option { 38 | return meterProviderOption{provider} 39 | } 40 | 41 | type tracerProviderOption struct{ trace.TracerProvider } 42 | 43 | func (o tracerProviderOption) apply(c *config) { 44 | c.TracerProvider = o.TracerProvider 45 | } 46 | 47 | // WithTracerProvider specifies the trace.TracerProvider instance to use for the instrumentation. 48 | // By default, the global trace.TracerProvider is used. 49 | func WithTracerProvider(provider trace.TracerProvider) Option { 50 | return tracerProviderOption{provider} 51 | } 52 | 53 | // newConfig computes a config from the supplied Options. 54 | func newConfig(opts ...Option) config { 55 | c := config{ 56 | MeterProvider: otel.GetMeterProvider(), 57 | TracerProvider: otel.GetTracerProvider(), 58 | } 59 | 60 | for _, opt := range opts { 61 | opt.apply(&c) 62 | } 63 | 64 | return c 65 | } 66 | -------------------------------------------------------------------------------- /opentelemetry/doc.go: -------------------------------------------------------------------------------- 1 | // Package opentelemetry provides extension components for eventually library 2 | // to enable OpenTelemetry instrumentation. 3 | package opentelemetry 4 | -------------------------------------------------------------------------------- /opentelemetry/event_store.go: -------------------------------------------------------------------------------- 1 | package opentelemetry 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "go.opentelemetry.io/otel/attribute" 9 | "go.opentelemetry.io/otel/metric" 10 | "go.opentelemetry.io/otel/trace" 11 | 12 | "github.com/get-eventually/go-eventually/event" 13 | "github.com/get-eventually/go-eventually/version" 14 | ) 15 | 16 | // Attribute keys used by the InstrumentedEventStore instrumentation. 17 | const ( 18 | EventStreamIDKey attribute.Key = "event_stream.id" 19 | EventStreamVersionSelectorKey attribute.Key = "event_stream.select_from_version" 20 | EventStreamExpectedVersionKey attribute.Key = "event_stream.expected_version" 21 | EventStoreNumEventsKey attribute.Key = "event_store.num_events" 22 | ) 23 | 24 | var _ event.Store = new(InstrumentedEventStore) 25 | 26 | // InstrumentedEventStore is a wrapper type over an event.Store 27 | // instance to provide instrumentation, in the form of metrics and traces 28 | // using OpenTelemetry. 29 | // 30 | // Use NewInstrumentedEventStore for constructing a new instance of this type. 31 | type InstrumentedEventStore struct { 32 | eventStore event.Store 33 | 34 | tracer trace.Tracer 35 | streamDuration metric.Int64Histogram 36 | appendDuration metric.Int64Histogram 37 | } 38 | 39 | func (ies *InstrumentedEventStore) registerMetrics(meter metric.Meter) error { 40 | var err error 41 | 42 | if ies.streamDuration, err = meter.Int64Histogram( 43 | "eventually.event_store.stream.duration.milliseconds", 44 | metric.WithUnit("ms"), 45 | metric.WithDescription("Duration in milliseconds of event.Store.Stream operations performed."), 46 | ); err != nil { 47 | return fmt.Errorf("oteleventually.InstrumentedEventStore: failed to register metric, %w", err) 48 | } 49 | 50 | if ies.appendDuration, err = meter.Int64Histogram( 51 | "eventually.event_store.append.duration.milliseconds", 52 | metric.WithUnit("ms"), 53 | metric.WithDescription("Duration in milliseconds of event.Store.Append operations performed."), 54 | ); err != nil { 55 | return fmt.Errorf("oteleventually.InstrumentedEventStore: failed to register metric, %w", err) 56 | } 57 | 58 | return nil 59 | } 60 | 61 | // NewInstrumentedEventStore returns a wrapper type to provide OpenTelemetry 62 | // instrumentation (metrics and traces) around an event.Store. 63 | // 64 | // An error is returned if metrics could not be registered. 65 | func NewInstrumentedEventStore(eventStore event.Store, options ...Option) (*InstrumentedEventStore, error) { 66 | cfg := newConfig(options...) 67 | 68 | ies := &InstrumentedEventStore{ 69 | eventStore: eventStore, 70 | tracer: cfg.tracer(), 71 | streamDuration: nil, 72 | appendDuration: nil, 73 | } 74 | 75 | if err := ies.registerMetrics(cfg.meter()); err != nil { 76 | return nil, err 77 | } 78 | 79 | return ies, nil 80 | } 81 | 82 | // Stream calls the wrapped event.Store.Stream method and records metrics and traces around it. 83 | func (ies *InstrumentedEventStore) Stream( 84 | ctx context.Context, 85 | stream event.StreamWrite, 86 | id event.StreamID, 87 | selector version.Selector, 88 | ) (err error) { 89 | attributes := []attribute.KeyValue{ 90 | EventStreamIDKey.String(string(id)), 91 | EventStreamVersionSelectorKey.Int64(int64(selector.From)), 92 | } 93 | 94 | ctx, span := ies.tracer.Start(ctx, "event.Store.Stream", trace.WithAttributes(attributes...)) 95 | start := time.Now() 96 | 97 | defer func() { 98 | duration := time.Since(start) 99 | ies.streamDuration.Record(ctx, duration.Milliseconds(), metric.WithAttributes( 100 | ErrorAttribute.Bool(err != nil), 101 | )) 102 | 103 | if err != nil { 104 | span.RecordError(err) 105 | } 106 | 107 | span.End() 108 | }() 109 | 110 | err = ies.eventStore.Stream(ctx, stream, id, selector) 111 | 112 | return 113 | } 114 | 115 | // Append calls the wrapped event.Store.Append method and records metrics and traces around it. 116 | func (ies *InstrumentedEventStore) Append( 117 | ctx context.Context, 118 | id event.StreamID, 119 | expected version.Check, 120 | events ...event.Envelope, 121 | ) (newVersion version.Version, err error) { 122 | expectedVersion := int64(-1) 123 | if v, ok := expected.(version.CheckExact); ok { 124 | expectedVersion = int64(v) 125 | } 126 | 127 | attributes := []attribute.KeyValue{ 128 | EventStreamIDKey.String(string(id)), 129 | EventStreamExpectedVersionKey.Int64(expectedVersion), 130 | EventStoreNumEventsKey.Int(len(events)), 131 | } 132 | 133 | ctx, span := ies.tracer.Start(ctx, "event.Store.Append", trace.WithAttributes(attributes...)) 134 | start := time.Now() 135 | 136 | defer func() { 137 | duration := time.Since(start) 138 | ies.streamDuration.Record(ctx, duration.Milliseconds(), metric.WithAttributes( 139 | ErrorAttribute.Bool(err != nil), 140 | )) 141 | 142 | if err != nil { 143 | span.RecordError(err) 144 | } 145 | 146 | span.End() 147 | }() 148 | 149 | return ies.eventStore.Append(ctx, id, expected, events...) 150 | } 151 | -------------------------------------------------------------------------------- /opentelemetry/repository.go: -------------------------------------------------------------------------------- 1 | package opentelemetry 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "go.opentelemetry.io/otel/attribute" 9 | "go.opentelemetry.io/otel/metric" 10 | "go.opentelemetry.io/otel/trace" 11 | 12 | "github.com/get-eventually/go-eventually/aggregate" 13 | ) 14 | 15 | // Attribute keys used by the InstrumentedRepository instrumentation. 16 | const ( 17 | ErrorAttribute attribute.Key = "error" 18 | AggregateTypeAttribute attribute.Key = "aggregate.type" 19 | AggregateVersionAttribute attribute.Key = "aggregate.version" 20 | AggregateIDAttribute attribute.Key = "aggregate.id" 21 | ) 22 | 23 | // InstrumentedRepository is a wrapper type over an aggregate.Repository 24 | // instance to provide instrumentation, in the form of metrics and traces 25 | // using OpenTelemetry. 26 | // 27 | // Use NewInstrumentedRepository for constructing a new instance of this type. 28 | type InstrumentedRepository[I aggregate.ID, T aggregate.Root[I]] struct { 29 | aggregateType aggregate.Type[I, T] 30 | repository aggregate.Repository[I, T] 31 | 32 | tracer trace.Tracer 33 | getDuration metric.Int64Histogram 34 | saveDuration metric.Int64Histogram 35 | } 36 | 37 | func (ir *InstrumentedRepository[I, T]) registerMetrics(meter metric.Meter) error { 38 | var err error 39 | 40 | if ir.getDuration, err = meter.Int64Histogram( 41 | "eventually.repository.get.duration.milliseconds", 42 | metric.WithUnit("ms"), 43 | metric.WithDescription("Duration in milliseconds of aggregate.Repository.Get operations performed."), 44 | ); err != nil { 45 | return fmt.Errorf("oteleventually.InstrumentedRepository: failed to register metric, %w", err) 46 | } 47 | 48 | if ir.saveDuration, err = meter.Int64Histogram( 49 | "eventually.repository.save.duration.milliseconds", 50 | metric.WithUnit("ms"), 51 | metric.WithDescription("Duration in milliseconds of aggregate.Repository.Save operations performed."), 52 | ); err != nil { 53 | return fmt.Errorf("oteleventually.InstrumentedRepository: failed to register metric, %w", err) 54 | } 55 | 56 | return nil 57 | } 58 | 59 | // NewInstrumentedRepository returns a wrapper type to provide OpenTelemetry 60 | // instrumentation (metrics and traces) around an aggregate.Repository. 61 | // 62 | // The aggregate.Type for the Repository is also used for reporting the 63 | // Aggregate Type name as an attribute. 64 | // 65 | // An error is returned if metrics could not be registered. 66 | func NewInstrumentedRepository[I aggregate.ID, T aggregate.Root[I]]( 67 | aggregateType aggregate.Type[I, T], 68 | repository aggregate.Repository[I, T], 69 | options ...Option, 70 | ) (*InstrumentedRepository[I, T], error) { 71 | cfg := newConfig(options...) 72 | 73 | ir := &InstrumentedRepository[I, T]{ 74 | aggregateType: aggregateType, 75 | repository: repository, 76 | tracer: cfg.tracer(), 77 | getDuration: nil, 78 | saveDuration: nil, 79 | } 80 | 81 | if err := ir.registerMetrics(cfg.meter()); err != nil { 82 | return nil, err 83 | } 84 | 85 | return ir, nil 86 | } 87 | 88 | // Get calls the wrapped aggregate.Repository.Get method and records metrics 89 | // and traces around it. 90 | func (ir *InstrumentedRepository[I, T]) Get(ctx context.Context, id I) (result T, err error) { 91 | attributes := []attribute.KeyValue{ 92 | AggregateTypeAttribute.String(ir.aggregateType.Name), 93 | } 94 | 95 | //nolint:gocritic // Not appending to the same slice done on purpose. 96 | spanAttributes := append(attributes, 97 | AggregateIDAttribute.String(id.String()), 98 | ) 99 | 100 | ctx, span := ir.tracer.Start(ctx, "aggregate.Repository.Get", trace.WithAttributes(spanAttributes...)) 101 | start := time.Now() 102 | 103 | defer func() { 104 | attributes := append(attributes, ErrorAttribute.Bool(err != nil)) 105 | 106 | duration := time.Since(start) 107 | ir.getDuration.Record(ctx, duration.Milliseconds(), metric.WithAttributes(attributes...)) 108 | 109 | if err != nil { 110 | span.RecordError(err) 111 | } else { 112 | span.SetAttributes(AggregateVersionAttribute.Int64(int64(result.Version()))) 113 | } 114 | 115 | span.End() 116 | }() 117 | 118 | result, err = ir.repository.Get(ctx, id) 119 | 120 | return result, err 121 | } 122 | 123 | // Save calls the wrapped aggregate.Repository.Save method and records metrics 124 | // and traces around it. 125 | func (ir *InstrumentedRepository[I, T]) Save(ctx context.Context, root T) (err error) { 126 | attributes := []attribute.KeyValue{ 127 | AggregateTypeAttribute.String(ir.aggregateType.Name), 128 | } 129 | 130 | //nolint:gocritic // Not appending to the same slice done on purpose. 131 | spanAttributes := append(attributes, 132 | AggregateIDAttribute.String(root.AggregateID().String()), 133 | AggregateVersionAttribute.Int64(int64(root.Version())), 134 | ) 135 | 136 | ctx, span := ir.tracer.Start(ctx, "aggregate.Repository.Save", trace.WithAttributes(spanAttributes...)) 137 | start := time.Now() 138 | 139 | defer func() { 140 | attributes := append(attributes, ErrorAttribute.Bool(err != nil)) 141 | 142 | duration := time.Since(start) 143 | ir.saveDuration.Record(ctx, duration.Milliseconds(), metric.WithAttributes(attributes...)) 144 | 145 | if err != nil { 146 | span.RecordError(err) 147 | } 148 | 149 | span.End() 150 | }() 151 | 152 | err = ir.repository.Save(ctx, root) 153 | 154 | return 155 | } 156 | -------------------------------------------------------------------------------- /postgres/aggregate_repository.go: -------------------------------------------------------------------------------- 1 | package postgres 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | 8 | "github.com/jackc/pgx/v5" 9 | "github.com/jackc/pgx/v5/pgxpool" 10 | 11 | "github.com/get-eventually/go-eventually/aggregate" 12 | "github.com/get-eventually/go-eventually/event" 13 | "github.com/get-eventually/go-eventually/message" 14 | "github.com/get-eventually/go-eventually/postgres/internal" 15 | "github.com/get-eventually/go-eventually/serde" 16 | "github.com/get-eventually/go-eventually/version" 17 | ) 18 | 19 | // AggregateRepository implements the aggregate.Repository interface 20 | // for PostgreSQL databases. 21 | // 22 | // This implementation uses the "aggregates" table (by default) in the database 23 | // as its main operational table. 24 | // 25 | // At the same time, it also writes 26 | // to both "events" and "event_streams" to append the Domain events 27 | // recorded by Aggregate Roots. These updates are performed within the same transaction. 28 | // 29 | // Note: the tables the Repository points to can be changed using the 30 | // available functional options. 31 | type AggregateRepository[ID aggregate.ID, T aggregate.Root[ID]] struct { 32 | conn *pgxpool.Pool 33 | aggregateType aggregate.Type[ID, T] 34 | aggregateSerde serde.Bytes[T] 35 | messageSerde serde.Bytes[message.Message] 36 | 37 | aggregateTableName string 38 | eventsTableName string 39 | streamsTableName string 40 | } 41 | 42 | // NewAggregateRepository returns a new AggregateRepository instance. 43 | func NewAggregateRepository[ID aggregate.ID, T aggregate.Root[ID]]( 44 | conn *pgxpool.Pool, 45 | aggregateType aggregate.Type[ID, T], 46 | aggregateSerde serde.Bytes[T], 47 | messageSerde serde.Bytes[message.Message], 48 | options ...Option[*AggregateRepository[ID, T]], 49 | ) AggregateRepository[ID, T] { 50 | repo := AggregateRepository[ID, T]{ 51 | conn: conn, 52 | aggregateType: aggregateType, 53 | aggregateSerde: aggregateSerde, 54 | messageSerde: messageSerde, 55 | aggregateTableName: DefaultAggregateTableName, 56 | eventsTableName: DefaultEventsTableName, 57 | streamsTableName: DefaultStreamsTableName, 58 | } 59 | 60 | for _, opt := range options { 61 | opt.apply(&repo) 62 | } 63 | 64 | return repo 65 | } 66 | 67 | // Get returns the aggregate.Root instance specified by the provided id. 68 | // Returns aggregate.ErrRootNotFound if the Aggregate Root doesn't exist. 69 | func (repo AggregateRepository[ID, T]) Get(ctx context.Context, id ID) (T, error) { 70 | return repo.get(ctx, repo.conn, id) 71 | } 72 | 73 | type queryRower interface { 74 | QueryRow(context.Context, string, ...interface{}) pgx.Row 75 | } 76 | 77 | const getAggregateQueryTemplate = ` 78 | SELECT version, state 79 | FROM %s 80 | WHERE aggregate_id = $1 AND "type" = $2 81 | ` 82 | 83 | func (repo AggregateRepository[ID, T]) get(ctx context.Context, tx queryRower, id ID) (T, error) { 84 | var zeroValue T 85 | 86 | row := tx.QueryRow( 87 | ctx, 88 | fmt.Sprintf(getAggregateQueryTemplate, repo.aggregateTableName), 89 | id.String(), repo.aggregateType.Name, 90 | ) 91 | 92 | var ( 93 | v version.Version 94 | state []byte 95 | ) 96 | 97 | if err := row.Scan(&v, &state); errors.Is(err, pgx.ErrNoRows) { 98 | return zeroValue, aggregate.ErrRootNotFound 99 | } else if err != nil { 100 | return zeroValue, fmt.Errorf( 101 | "postgres.AggregateRepository: failed to fetch aggregate state from database, %w", 102 | err, 103 | ) 104 | } 105 | 106 | root, err := aggregate.RehydrateFromState(v, state, repo.aggregateSerde) 107 | if err != nil { 108 | return zeroValue, fmt.Errorf( 109 | "postgres.AggregateRepository: failed to deserialize state into aggregate root object, %w", 110 | err, 111 | ) 112 | } 113 | 114 | return root, nil 115 | } 116 | 117 | // Save saves the new state of the provided aggregate.Root instance. 118 | func (repo AggregateRepository[ID, T]) Save(ctx context.Context, root T) (err error) { 119 | txOpts := pgx.TxOptions{ //nolint:exhaustruct // We don't need all fields. 120 | IsoLevel: pgx.Serializable, 121 | AccessMode: pgx.ReadWrite, 122 | DeferrableMode: pgx.Deferrable, 123 | } 124 | 125 | return internal.RunTransaction(ctx, repo.conn, txOpts, func(ctx context.Context, tx pgx.Tx) error { 126 | eventsToCommit := root.FlushRecordedEvents() 127 | expectedRootVersion := root.Version() - version.Version(len(eventsToCommit)) //nolint:gosec // This should not overflow. 128 | eventStreamID := event.StreamID(root.AggregateID().String()) 129 | 130 | newEventStreamVersion, err := appendDomainEvents( 131 | ctx, tx, 132 | repo.eventsTableName, repo.streamsTableName, 133 | repo.messageSerde, 134 | eventStreamID, 135 | version.CheckExact(expectedRootVersion), 136 | eventsToCommit..., 137 | ) 138 | if err != nil { 139 | return err 140 | } 141 | 142 | if newEventStreamVersion != root.Version() { 143 | return repo.saveErr("version mismatch between event stream and aggregate", version.ConflictError{ 144 | Expected: newEventStreamVersion, 145 | Actual: root.Version(), 146 | }) 147 | } 148 | 149 | return repo.saveAggregateState(ctx, tx, eventStreamID, root) 150 | }) 151 | } 152 | 153 | const saveAggregateQueryTemplate = ` 154 | INSERT INTO %s (aggregate_id, "type", "version", "state") 155 | VALUES ($1, $2, $3, $4) 156 | ON CONFLICT (aggregate_id) DO 157 | UPDATE SET "version" = $3, "state" = $4 158 | ` 159 | 160 | func (repo AggregateRepository[ID, T]) saveAggregateState( 161 | ctx context.Context, 162 | tx pgx.Tx, 163 | id event.StreamID, 164 | root T, 165 | ) error { 166 | state, err := repo.aggregateSerde.Serialize(root) 167 | if err != nil { 168 | return repo.saveErr("failed to serialize aggregate root into wire format, %w", err) 169 | } 170 | 171 | if _, err := tx.Exec( 172 | ctx, 173 | fmt.Sprintf(saveAggregateQueryTemplate, repo.aggregateTableName), 174 | id, repo.aggregateType.Name, root.Version(), state, 175 | ); err != nil { 176 | return repo.saveErr("failed to save new aggregate state, %w", err) 177 | } 178 | 179 | return nil 180 | } 181 | 182 | func (repo AggregateRepository[ID, T]) saveErr(msg string, args ...any) error { 183 | return fmt.Errorf("postgres.AggregateRepository: "+msg, args...) 184 | } 185 | -------------------------------------------------------------------------------- /postgres/aggregate_repository_test.go: -------------------------------------------------------------------------------- 1 | package postgres_test 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "testing" 7 | 8 | "github.com/google/uuid" 9 | "github.com/jackc/pgx/v5/pgxpool" 10 | _ "github.com/jackc/pgx/v5/stdlib" // Used to bring in the driver for sql.Open. 11 | "github.com/stretchr/testify/require" 12 | 13 | "github.com/get-eventually/go-eventually/internal/user" 14 | userv1 "github.com/get-eventually/go-eventually/internal/user/gen/user/v1" 15 | "github.com/get-eventually/go-eventually/postgres" 16 | "github.com/get-eventually/go-eventually/postgres/internal" 17 | "github.com/get-eventually/go-eventually/serde" 18 | ) 19 | 20 | func TestAggregateRepository(t *testing.T) { 21 | if testing.Short() { 22 | t.SkipNow() 23 | } 24 | 25 | ctx := context.Background() 26 | 27 | container, err := internal.NewPostgresContainer(ctx) 28 | require.NoError(t, err) 29 | 30 | defer func() { 31 | require.NoError(t, container.Terminate(ctx)) 32 | }() 33 | 34 | db, err := sql.Open("pgx", container.ConnectionDSN) 35 | require.NoError(t, err) 36 | require.NoError(t, postgres.RunMigrations(db)) 37 | require.NoError(t, db.Close()) 38 | 39 | conn, err := pgxpool.New(ctx, container.ConnectionDSN) 40 | require.NoError(t, err) 41 | 42 | user.AggregateRepositorySuite(postgres.NewAggregateRepository( 43 | conn, user.Type, 44 | serde.Chain( 45 | user.ProtoSerde, 46 | serde.NewProtoJSON(func() *userv1.User { return new(userv1.User) }), 47 | ), 48 | serde.Chain( 49 | user.EventProtoSerde, 50 | serde.NewProtoJSON(func() *userv1.Event { return new(userv1.Event) }), 51 | ), 52 | postgres.WithAggregateTableName[uuid.UUID, *user.User](postgres.DefaultAggregateTableName), 53 | postgres.WithEventsTableName[uuid.UUID, *user.User](postgres.DefaultEventsTableName), 54 | postgres.WithStreamsTableName[uuid.UUID, *user.User](postgres.DefaultStreamsTableName), 55 | ))(t) 56 | } 57 | -------------------------------------------------------------------------------- /postgres/append_domain_events.go: -------------------------------------------------------------------------------- 1 | package postgres 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "strconv" 9 | "time" 10 | 11 | "github.com/jackc/pgx/v5" 12 | 13 | "github.com/get-eventually/go-eventually/event" 14 | "github.com/get-eventually/go-eventually/message" 15 | "github.com/get-eventually/go-eventually/serde" 16 | "github.com/get-eventually/go-eventually/version" 17 | ) 18 | 19 | const ( 20 | getEventStreamQueryTemplate = ` 21 | SELECT version 22 | FROM %s 23 | WHERE event_stream_id = $1 24 | ` 25 | 26 | updateEventStreamQueryTemplate = ` 27 | INSERT INTO %s (event_stream_id, version) 28 | VALUES ($1, $2) 29 | ON CONFLICT (event_stream_id) DO 30 | UPDATE SET version = $2 31 | ` 32 | ) 33 | 34 | func appendDomainEvents( 35 | ctx context.Context, 36 | tx pgx.Tx, 37 | eventsTableName, streamsTableName string, 38 | messageSerializer serde.Serializer[message.Message, []byte], 39 | id event.StreamID, 40 | expected version.Check, 41 | events ...event.Envelope, 42 | ) (version.Version, error) { 43 | row := tx.QueryRow( 44 | ctx, 45 | fmt.Sprintf(getEventStreamQueryTemplate, streamsTableName), 46 | id, 47 | ) 48 | 49 | var oldVersion version.Version 50 | if err := row.Scan(&oldVersion); err != nil && !errors.Is(err, pgx.ErrNoRows) { 51 | return 0, fmt.Errorf("postgres.appendDomainEvents: failed to scan old event stream version, %w", err) 52 | } 53 | 54 | if v, ok := expected.(version.CheckExact); ok && oldVersion != version.Version(v) { 55 | return 0, fmt.Errorf( 56 | "postgres.appendDomainEvents: event stream version check failed, %w", 57 | version.ConflictError{ 58 | Expected: version.Version(v), 59 | Actual: oldVersion, 60 | }, 61 | ) 62 | } 63 | 64 | newVersion := oldVersion + version.Version(len(events)) //nolint:gosec // This should not overflow. 65 | 66 | if _, err := tx.Exec( 67 | ctx, 68 | fmt.Sprintf(updateEventStreamQueryTemplate, streamsTableName), 69 | id, newVersion, 70 | ); err != nil { 71 | return 0, fmt.Errorf("postgres.EventStore: failed to update event stream, %w", err) 72 | } 73 | 74 | for i, event := range events { 75 | eventVersion := oldVersion + version.Version(i) + 1 //nolint:gosec // This should not overflow. 76 | 77 | if err := appendDomainEvent( 78 | ctx, tx, 79 | eventsTableName, messageSerializer, 80 | id, eventVersion, newVersion, event, 81 | ); err != nil { 82 | return 0, err 83 | } 84 | } 85 | 86 | return newVersion, nil 87 | } 88 | 89 | const appendDomainEventQueryTemplate = ` 90 | INSERT INTO %s (event_stream_id, "type", "version", event, metadata) 91 | VALUES ($1, $2, $3, $4, $5) 92 | ` 93 | 94 | func appendDomainEvent( 95 | ctx context.Context, 96 | tx pgx.Tx, 97 | eventsTableName string, 98 | messageSerializer serde.Serializer[message.Message, []byte], 99 | id event.StreamID, 100 | eventVersion, newVersion version.Version, 101 | evt event.Envelope, 102 | ) error { 103 | msg := evt.Message 104 | 105 | data, err := messageSerializer.Serialize(msg) 106 | if err != nil { 107 | return fmt.Errorf("postgres.appendDomainEvent: failed to serialize domain event, %w", err) 108 | } 109 | 110 | enrichedMetadata := evt.Metadata. 111 | With("Recorded-At", time.Now().Format(time.RFC3339Nano)). 112 | With("Recorded-With-New-Overall-Version", strconv.Itoa(int(newVersion))) 113 | 114 | metadata, err := serializeMetadata(enrichedMetadata) 115 | if err != nil { 116 | return err 117 | } 118 | 119 | if _, err = tx.Exec( 120 | ctx, 121 | fmt.Sprintf(appendDomainEventQueryTemplate, eventsTableName), 122 | id, msg.Name(), eventVersion, data, metadata, 123 | ); err != nil { 124 | return fmt.Errorf("postgres.appendDomainEvent: failed to append new domain event to event store, %w", err) 125 | } 126 | 127 | return nil 128 | } 129 | 130 | func serializeMetadata(metadata message.Metadata) ([]byte, error) { 131 | if metadata == nil { 132 | return nil, nil 133 | } 134 | 135 | data, err := json.Marshal(metadata) 136 | if err != nil { 137 | return nil, fmt.Errorf("postgres.serializeMetadata: failed to marshal to json, %w", err) 138 | } 139 | 140 | return data, nil 141 | } 142 | -------------------------------------------------------------------------------- /postgres/doc.go: -------------------------------------------------------------------------------- 1 | // Package postgres contains implementations of go-eventually interfaces 2 | // specific to PostgreSQL, such as Aggregate Repository, Event Store, etc. 3 | package postgres 4 | -------------------------------------------------------------------------------- /postgres/event_store.go: -------------------------------------------------------------------------------- 1 | package postgres 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | 9 | "github.com/jackc/pgx/v5" 10 | "github.com/jackc/pgx/v5/pgxpool" 11 | 12 | "github.com/get-eventually/go-eventually/event" 13 | "github.com/get-eventually/go-eventually/message" 14 | "github.com/get-eventually/go-eventually/postgres/internal" 15 | "github.com/get-eventually/go-eventually/serde" 16 | "github.com/get-eventually/go-eventually/version" 17 | ) 18 | 19 | //nolint:exhaustruct // Interface implementation assertion. 20 | var _ event.Store = EventStore{} 21 | 22 | // EventStore is an event.Store implementation targeted to PostgreSQL databases. 23 | // 24 | // The implementation uses "event_streams" and "events" as their 25 | // operational tables. Updates to these tables are transactional. 26 | type EventStore struct { 27 | conn *pgxpool.Pool 28 | messageSerde serde.Bytes[message.Message] 29 | } 30 | 31 | // NewEventStore returns a new EventStore instance. 32 | func NewEventStore(conn *pgxpool.Pool, messageSerde serde.Bytes[message.Message]) EventStore { 33 | return EventStore{ 34 | conn: conn, 35 | messageSerde: messageSerde, 36 | } 37 | } 38 | 39 | // Stream implements the event.Streamer interface. 40 | func (es EventStore) Stream( 41 | ctx context.Context, 42 | stream event.StreamWrite, 43 | id event.StreamID, 44 | selector version.Selector, 45 | ) error { 46 | defer close(stream) 47 | 48 | rows, err := es.conn.Query( 49 | ctx, 50 | `SELECT version, event, metadata FROM events 51 | WHERE event_stream_id = $1 AND version >= $2 52 | ORDER BY version`, 53 | id, selector.From, 54 | ) 55 | 56 | if errors.Is(err, pgx.ErrNoRows) { 57 | return nil 58 | } 59 | 60 | if err != nil { 61 | return fmt.Errorf("postgres.EventStore: failed to query events table, %w", err) 62 | } 63 | 64 | for rows.Next() { 65 | var ( 66 | rawEvent []byte 67 | rawMetadata json.RawMessage 68 | eventVersion version.Version 69 | ) 70 | 71 | if err := rows.Scan(&eventVersion, &rawEvent, &rawMetadata); err != nil { 72 | return fmt.Errorf("postgres.EventStore: failed to scan next row") 73 | } 74 | 75 | msg, err := es.messageSerde.Deserialize(rawEvent) 76 | if err != nil { 77 | return fmt.Errorf("postgres.EventStore: failed to deserialize event, %w", err) 78 | } 79 | 80 | var metadata message.Metadata 81 | if err := json.Unmarshal(rawMetadata, &metadata); err != nil { 82 | return fmt.Errorf("postgres.EventStore: failed to deserialize metadata, %w", err) 83 | } 84 | 85 | stream <- event.Persisted{ 86 | StreamID: id, 87 | Version: eventVersion, 88 | Envelope: event.Envelope{ 89 | Message: msg, 90 | Metadata: metadata, 91 | }, 92 | } 93 | } 94 | 95 | return nil 96 | } 97 | 98 | // Append implements event.Store. 99 | func (es EventStore) Append( 100 | ctx context.Context, 101 | id event.StreamID, 102 | expected version.Check, 103 | events ...event.Envelope, 104 | ) (version.Version, error) { 105 | var newVersion version.Version 106 | 107 | txOpts := pgx.TxOptions{ //nolint:exhaustruct // We don't need all fields. 108 | IsoLevel: pgx.Serializable, 109 | AccessMode: pgx.ReadWrite, 110 | DeferrableMode: pgx.Deferrable, 111 | } 112 | 113 | if err := internal.RunTransaction(ctx, es.conn, txOpts, func(ctx context.Context, tx pgx.Tx) error { 114 | var err error 115 | 116 | if newVersion, err = appendDomainEvents( 117 | ctx, tx, 118 | DefaultEventsTableName, DefaultStreamsTableName, 119 | es.messageSerde, 120 | id, expected, events..., 121 | ); err != nil { 122 | return fmt.Errorf("postgres.EventStore: failed to append domain events, %w", err) 123 | } 124 | 125 | return nil 126 | }); err != nil { 127 | return 0, err 128 | } 129 | 130 | return newVersion, nil 131 | } 132 | -------------------------------------------------------------------------------- /postgres/event_store_test.go: -------------------------------------------------------------------------------- 1 | package postgres_test 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "testing" 7 | 8 | "github.com/jackc/pgx/v5/pgxpool" 9 | _ "github.com/jackc/pgx/v5/stdlib" // Used to bring in the driver for sql.Open. 10 | "github.com/stretchr/testify/require" 11 | 12 | "github.com/get-eventually/go-eventually/internal/user" 13 | userv1 "github.com/get-eventually/go-eventually/internal/user/gen/user/v1" 14 | "github.com/get-eventually/go-eventually/postgres" 15 | "github.com/get-eventually/go-eventually/postgres/internal" 16 | "github.com/get-eventually/go-eventually/serde" 17 | ) 18 | 19 | func TestEventStore(t *testing.T) { 20 | if testing.Short() { 21 | t.SkipNow() 22 | } 23 | 24 | ctx := context.Background() 25 | 26 | container, err := internal.NewPostgresContainer(ctx) 27 | require.NoError(t, err) 28 | 29 | defer func() { 30 | require.NoError(t, container.Terminate(ctx)) 31 | }() 32 | 33 | db, err := sql.Open("pgx", container.ConnectionDSN) 34 | require.NoError(t, err) 35 | require.NoError(t, postgres.RunMigrations(db)) 36 | require.NoError(t, db.Close()) 37 | 38 | conn, err := pgxpool.New(ctx, container.ConnectionDSN) 39 | require.NoError(t, err) 40 | 41 | user.EventStoreSuite(postgres.NewEventStore(conn, serde.Chain( 42 | user.EventProtoSerde, 43 | serde.NewProtoJSON(func() *userv1.Event { return new(userv1.Event) }), 44 | )))(t) 45 | } 46 | -------------------------------------------------------------------------------- /postgres/internal/container.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/jackc/pgx/v5" 9 | "github.com/testcontainers/testcontainers-go" 10 | "github.com/testcontainers/testcontainers-go/modules/postgres" 11 | "github.com/testcontainers/testcontainers-go/wait" 12 | ) 13 | 14 | // PostgresContainer returns an handle on a Postgres container 15 | // started through testcontainers. 16 | type PostgresContainer struct { 17 | *postgres.PostgresContainer 18 | 19 | ConnectionDSN string 20 | PostgresConfig *pgx.ConnConfig 21 | } 22 | 23 | // NewPostgresContainer creates and starts a new Postgres container 24 | // using testcontainers, then returns a handle to said container 25 | // to manage its lifecycle. 26 | func NewPostgresContainer(ctx context.Context) (*PostgresContainer, error) { 27 | withContext := func(msg string, err error) error { 28 | return fmt.Errorf("internal.NewPostgresContainer: %s, %w", msg, err) 29 | } 30 | 31 | container, err := postgres.Run( 32 | ctx, 33 | "postgres:16-alpine", 34 | postgres.WithDatabase("main"), 35 | postgres.WithUsername("postgres"), 36 | postgres.WithPassword("notasecret"), 37 | testcontainers.WithWaitStrategy( 38 | //nolint:mnd // It's ok to use a magic number here. 39 | wait.ForLog("database system is ready to accept connections"). 40 | WithOccurrence(2). 41 | WithStartupTimeout(5*time.Second), 42 | ), 43 | ) 44 | if err != nil { 45 | return nil, withContext("failed to run new container", err) 46 | } 47 | 48 | dsn, err := container.ConnectionString(ctx) 49 | if err != nil { 50 | return nil, withContext("failed to get connection dsn", err) 51 | } 52 | 53 | config, err := pgx.ParseConfig(dsn) 54 | if err != nil { 55 | return nil, withContext("failed to parse pgx config from dsn", err) 56 | } 57 | 58 | return &PostgresContainer{ 59 | PostgresContainer: container, 60 | ConnectionDSN: dsn, 61 | PostgresConfig: config, 62 | }, nil 63 | } 64 | -------------------------------------------------------------------------------- /postgres/internal/internal.go: -------------------------------------------------------------------------------- 1 | // Package internal contains utilities and helper functions useful 2 | // in the scope of eventually's postgres implementation. 3 | package internal 4 | -------------------------------------------------------------------------------- /postgres/internal/transaction.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/jackc/pgx/v5" 8 | ) 9 | 10 | // TxBeginner represents a pgx-related component that can initiate transactions. 11 | type TxBeginner interface { 12 | BeginTx(ctx context.Context, options pgx.TxOptions) (pgx.Tx, error) 13 | } 14 | 15 | // RunTransaction runs a critical data change path in a transaction, 16 | // seamlessly handling the transaction lifecycle (begin, commit, rollback). 17 | func RunTransaction( 18 | ctx context.Context, 19 | db TxBeginner, 20 | options pgx.TxOptions, //nolint:gocritic // The pgx API uses value semantics, will do the same here. 21 | do func(ctx context.Context, tx pgx.Tx) error, 22 | ) (err error) { 23 | withContext := func(msg string, err error) error { 24 | return fmt.Errorf("%s, %w", msg, err) 25 | } 26 | 27 | tx, err := db.BeginTx(ctx, options) 28 | if err != nil { 29 | return withContext("failed to begin transaction", err) 30 | } 31 | 32 | defer func() { 33 | if err == nil { 34 | return 35 | } 36 | 37 | if rollbackErr := tx.Rollback(ctx); rollbackErr != nil { 38 | err = fmt.Errorf("failed to rollback transaction, %w (caused by: %w)", rollbackErr, err) 39 | } 40 | }() 41 | 42 | if err := do(ctx, tx); err != nil { 43 | return withContext("failed to perform transaction", err) 44 | } 45 | 46 | if err = tx.Commit(ctx); err != nil { 47 | return withContext("failed to commit transaction", err) 48 | } 49 | 50 | return nil 51 | } 52 | -------------------------------------------------------------------------------- /postgres/migration.go: -------------------------------------------------------------------------------- 1 | package postgres 2 | 3 | import ( 4 | "database/sql" 5 | "embed" 6 | "errors" 7 | "fmt" 8 | 9 | "github.com/golang-migrate/migrate/v4" 10 | "github.com/golang-migrate/migrate/v4/database/pgx" 11 | "github.com/golang-migrate/migrate/v4/source/iofs" 12 | ) 13 | 14 | //go:embed migrations/*.sql 15 | var fs embed.FS 16 | 17 | // RunMigrations runs the latest migrations for the postgres integration. 18 | // 19 | // Make sure to run these in the entrypoint of your application, ideally 20 | // before building a postgres interface implementation. 21 | func RunMigrations(db *sql.DB) error { 22 | wrapErr := func(err error, msg string) error { 23 | return fmt.Errorf("postgres.RunMigrations: %s, %w", msg, err) 24 | } 25 | 26 | d, err := iofs.New(fs, "migrations") 27 | if err != nil { 28 | return wrapErr(err, "failed to create new iofs driver for reading migrations") 29 | } 30 | 31 | driver, err := pgx.WithInstance(db, &pgx.Config{ //nolint:exhaustruct // We don't need all fields. 32 | MigrationsTable: "eventually_schema_migrations", 33 | DatabaseName: "", 34 | SchemaName: "", 35 | StatementTimeout: 0, 36 | MigrationsTableQuoted: false, 37 | MultiStatementEnabled: false, 38 | MultiStatementMaxSize: 0, 39 | }) 40 | if err != nil { 41 | return wrapErr(err, "failed to create new migrate db instance") 42 | } 43 | 44 | m, err := migrate.NewWithInstance("iofs", d, "pgx", driver) 45 | if err != nil { 46 | return wrapErr(err, "failed to create new migrate source for running db migrations") 47 | } 48 | 49 | if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) { 50 | return wrapErr(err, "failed to execute migrations") 51 | } 52 | 53 | return nil 54 | } 55 | -------------------------------------------------------------------------------- /postgres/migrations/1_events.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE events; 2 | DROP TABLE event_streams; 3 | -------------------------------------------------------------------------------- /postgres/migrations/1_events.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE event_streams ( 2 | event_stream_id TEXT NOT NULL PRIMARY KEY, 3 | "version" INTEGER NOT NULL CHECK ("version" > 0) 4 | ); 5 | 6 | CREATE TABLE events ( 7 | event_stream_id TEXT NOT NULL, 8 | "type" TEXT NOT NULL, 9 | "version" INTEGER NOT NULL CHECK ("version" > 0), 10 | "event" BYTEA NOT NULL, 11 | metadata JSONB, 12 | 13 | PRIMARY KEY (event_stream_id, "version"), 14 | FOREIGN KEY (event_stream_id) REFERENCES event_streams (event_stream_id) ON DELETE CASCADE 15 | ); 16 | 17 | CREATE INDEX event_stream_id_idx ON events (event_stream_id); 18 | 19 | CREATE PROCEDURE upsert_event_stream( 20 | _event_stream_id TEXT, 21 | _expected_version INTEGER, 22 | _new_version INTEGER 23 | ) 24 | LANGUAGE PLPGSQL 25 | AS $$ 26 | DECLARE 27 | current_event_stream_version INTEGER; 28 | BEGIN 29 | -- Retrieve the latest version for the target Event Stream. 30 | SELECT es."version" 31 | INTO current_event_stream_version 32 | FROM event_streams es 33 | WHERE es.event_stream_id = _event_stream_id; 34 | 35 | IF (NOT FOUND AND _expected_version <> 0) OR (current_event_stream_version <> _expected_version) 36 | THEN 37 | RAISE EXCEPTION 'event stream version check failed, expected: %, got: %', _expected_version, current_event_stream_version; 38 | END IF; 39 | 40 | INSERT INTO event_streams (event_stream_id, "version") 41 | VALUES (_event_stream_id, _new_version) 42 | ON CONFLICT (event_stream_id) DO 43 | UPDATE SET "version" = _new_version; 44 | END; 45 | $$; 46 | 47 | CREATE FUNCTION upsert_event_stream_with_no_version_check( 48 | _event_stream_id TEXT, 49 | _new_version_offset INTEGER 50 | ) 51 | RETURNS INTEGER 52 | LANGUAGE PLPGSQL 53 | AS $$ 54 | DECLARE 55 | current_event_stream_version INTEGER; 56 | new_event_stream_version INTEGER; 57 | BEGIN 58 | -- Retrieve the latest version for the target Event Stream. 59 | SELECT es."version" 60 | INTO current_event_stream_version 61 | FROM event_streams es 62 | WHERE es.event_stream_id = _event_stream_id; 63 | 64 | IF NOT FOUND THEN 65 | current_event_stream_version := 0; 66 | END IF; 67 | 68 | new_event_stream_version := current_event_stream_version + _new_version_offset; 69 | 70 | INSERT INTO event_streams (event_stream_id, "version") 71 | VALUES (_event_stream_id, new_event_stream_version) 72 | ON CONFLICT (event_stream_id) DO 73 | UPDATE SET "version" = new_event_stream_version; 74 | 75 | RETURN new_event_stream_version; 76 | END; 77 | $$; 78 | -------------------------------------------------------------------------------- /postgres/migrations/2_aggregates.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE aggregates; 2 | DROP PROCEDURE upsert_aggregate; 3 | -------------------------------------------------------------------------------- /postgres/migrations/2_aggregates.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE aggregates ( 2 | aggregate_id TEXT NOT NULL PRIMARY KEY REFERENCES event_streams (event_stream_id) ON DELETE CASCADE, 3 | "type" TEXT NOT NULL, 4 | "version" INTEGER NOT NULL CHECK ("version" > 0), 5 | "state" BYTEA NOT NULL 6 | ); 7 | 8 | CREATE PROCEDURE upsert_aggregate( 9 | _aggregate_id TEXT, 10 | _type TEXT, 11 | _expected_version INTEGER, 12 | _new_version INTEGER, 13 | _state BYTEA 14 | ) 15 | LANGUAGE PLPGSQL 16 | AS $$ 17 | DECLARE 18 | current_aggregate_version INTEGER; 19 | BEGIN 20 | -- Retrieve the latest version for the target aggregate. 21 | SELECT a."version" 22 | INTO current_aggregate_version 23 | FROM aggregates a 24 | WHERE a.aggregate_id = _aggregate_id; 25 | 26 | IF (NOT FOUND AND _expected_version <> 0) OR (current_aggregate_version <> _expected_version) 27 | THEN 28 | RAISE EXCEPTION 'aggregate version check failed, expected: %, got: %', _expected_version, current_aggregate_version; 29 | END IF; 30 | 31 | -- An Aggregate Root is also an Event Stream. 32 | INSERT INTO event_streams (event_stream_id, "version") 33 | VALUES (_aggregate_id, _new_version) 34 | ON CONFLICT (event_stream_id) DO 35 | UPDATE SET "version" = _new_version; 36 | 37 | INSERT INTO aggregates (aggregate_id, "type", "version", "state") 38 | VALUES (_aggregate_id, _type, _new_version, _state) 39 | ON CONFLICT (aggregate_id) DO 40 | UPDATE SET "version" = _new_version, "state" = _state; 41 | END; 42 | $$; 43 | -------------------------------------------------------------------------------- /postgres/migrations/3_fix_version_column.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE event_streams ALTER COLUMN "version" TYPE INTEGER; 2 | ALTER TABLE events ALTER COLUMN "version" TYPE INTEGER; 3 | ALTER TABLE aggregates ALTER COLUMN "version" TYPE INTEGER; 4 | -------------------------------------------------------------------------------- /postgres/migrations/3_fix_version_column.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE event_streams ALTER COLUMN "version" TYPE BIGINT; 2 | ALTER TABLE events ALTER COLUMN "version" TYPE BIGINT; 3 | ALTER TABLE aggregates ALTER COLUMN "version" TYPE BIGINT; 4 | -------------------------------------------------------------------------------- /postgres/migrations/4_remove_procedures.down.sql: -------------------------------------------------------------------------------- 1 | -- Nothing to be done here: there is no coming back from this migration, 2 | -- since the new versions of the library don't use the procedures anymore. -------------------------------------------------------------------------------- /postgres/migrations/4_remove_procedures.up.sql: -------------------------------------------------------------------------------- 1 | DROP PROCEDURE upsert_aggregate; 2 | DROP PROCEDURE upsert_event_stream; 3 | DROP FUNCTION upsert_event_stream_with_no_version_check; -------------------------------------------------------------------------------- /postgres/option.go: -------------------------------------------------------------------------------- 1 | package postgres 2 | 3 | import "github.com/get-eventually/go-eventually/aggregate" 4 | 5 | // Option can be used to change the configuration of an object. 6 | type Option[T any] interface { 7 | apply(T) 8 | } 9 | 10 | type option[T any] func(T) 11 | 12 | func newOption[T any](f func(T)) option[T] { return option[T](f) } 13 | 14 | func (apply option[T]) apply(val T) { apply(val) } 15 | 16 | const ( 17 | // DefaultAggregateTableName is the default Aggregate table name an AggregateRepository points to. 18 | DefaultAggregateTableName = "aggregates" 19 | // DefaultEventsTableName is the default Domain Events table name an AggregateRepository points to. 20 | DefaultEventsTableName = "events" 21 | // DefaultStreamsTableName is the default Event Streams table name an AggregateRepository points to. 22 | DefaultStreamsTableName = "event_streams" 23 | ) 24 | 25 | // WithAggregateTableName allows you to specify a different Aggregate table name 26 | // that an AggregateRepository should manage. 27 | func WithAggregateTableName[ID aggregate.ID, T aggregate.Root[ID]]( 28 | tableName string, 29 | ) Option[*AggregateRepository[ID, T]] { 30 | return newOption(func(repository *AggregateRepository[ID, T]) { 31 | repository.aggregateTableName = tableName 32 | }) 33 | } 34 | 35 | // WithEventsTableName allows you to specify a different Events table name 36 | // that an AggregateRepository should manage. 37 | func WithEventsTableName[ID aggregate.ID, T aggregate.Root[ID]](tableName string) Option[*AggregateRepository[ID, T]] { 38 | return newOption(func(repository *AggregateRepository[ID, T]) { 39 | repository.eventsTableName = tableName 40 | }) 41 | } 42 | 43 | // WithStreamsTableName allows you to specify a different Event Streams table name 44 | // that an AggregateRepository should manage. 45 | func WithStreamsTableName[ID aggregate.ID, T aggregate.Root[ID]](tableName string) Option[*AggregateRepository[ID, T]] { 46 | return newOption(func(repository *AggregateRepository[ID, T]) { 47 | repository.streamsTableName = tableName 48 | }) 49 | } 50 | -------------------------------------------------------------------------------- /query/query.go: -------------------------------------------------------------------------------- 1 | // Package query provides support and utilities to handle and implement 2 | // Domain Queries in your application. 3 | package query 4 | 5 | import ( 6 | "context" 7 | 8 | "github.com/get-eventually/go-eventually/message" 9 | ) 10 | 11 | // Query represents a Domain Query, a request for information. 12 | // Queries should be phrased in the present, imperative tense, such as "ListUsers". 13 | type Query message.Message 14 | 15 | // Envelope represents a message containing a Domain Query, 16 | // and optionally includes additional fields in the form of Metadata. 17 | type Envelope[T Query] message.Envelope[T] 18 | 19 | // Handler is the interface that defines a Query Handler. 20 | // 21 | // Handler accepts a specific kind of Query, evaluates it 22 | // and returns the desired Result. 23 | type Handler[T Query, R any] interface { 24 | Handle(ctx context.Context, query Envelope[T]) (R, error) 25 | } 26 | 27 | // ToEnvelope is a convenience function that wraps the provided Query type 28 | // into an Envelope, with no metadata attached to it. 29 | func ToEnvelope[T Query](query T) Envelope[T] { 30 | return Envelope[T]{ 31 | Message: query, 32 | Metadata: nil, 33 | } 34 | } 35 | 36 | // HandlerFunc is a functional type that implements the Handler interface. 37 | // Useful for testing and stateless Handlers. 38 | type HandlerFunc[T Query, R any] func(ctx context.Context, query Envelope[T]) (R, error) 39 | 40 | // Handle implements xquery.Handler. 41 | func (f HandlerFunc[T, R]) Handle(ctx context.Context, query Envelope[T]) (R, error) { 42 | return f(ctx, query) 43 | } 44 | -------------------------------------------------------------------------------- /query/scenario.go: -------------------------------------------------------------------------------- 1 | package query 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | 10 | "github.com/get-eventually/go-eventually/event" 11 | "github.com/get-eventually/go-eventually/version" 12 | ) 13 | 14 | // ProcessorHandler is a Query Handler that can both handle domain queries, 15 | // and domain events to hydrate the query model. 16 | // 17 | // To be used in the Scenario. 18 | type ProcessorHandler[Q Query, R any] interface { 19 | Handler[Q, R] 20 | event.Processor 21 | } 22 | 23 | // ScenarioInit is the entrypoint of the Query Handler scenario API. 24 | // 25 | // A Query Handler scenario can either set the current evaluation context 26 | // by using Given(), or test a "clean-slate" scenario by using When() directly. 27 | type ScenarioInit[Q Query, R any, T ProcessorHandler[Q, R]] struct{} 28 | 29 | // Scenario can be used to test the result of Domain Queries 30 | // being handled by a Query Handler. 31 | // 32 | // Query Handlers in Event-sourced systems return read-only data on request by means 33 | // of Domain Queries. This scenario API helps you with testing the values 34 | // returned by a Query Handler when handling a specific Domain Query. 35 | func Scenario[Q Query, R any, T ProcessorHandler[Q, R]]() ScenarioInit[Q, R, T] { 36 | return ScenarioInit[Q, R, T]{} 37 | } 38 | 39 | // Given sets the Query Handler scenario preconditions. 40 | // 41 | // Domain Events are used in Event-sourced systems to represent a side effect 42 | // that has taken place in the system. In order to set a given state for the 43 | // system to be in while testing a specific Domain Query evaluation, you should 44 | // specify the Domain Events that have happened thus far. 45 | // 46 | // When you're testing Domain Queries with a clean-slate system, you should either specify 47 | // no Domain Events, or skip directly to When(). 48 | func (sc ScenarioInit[Q, R, T]) Given(events ...event.Persisted) ScenarioGiven[Q, R, T] { 49 | return ScenarioGiven[Q, R, T]{ 50 | given: events, 51 | } 52 | } 53 | 54 | // When provides the Domain Query to evaluate. 55 | func (sc ScenarioInit[Q, R, T]) When(q Envelope[Q]) ScenarioWhen[Q, R, T] { 56 | //nolint:exhaustruct // Zero values are fine here. 57 | return ScenarioWhen[Q, R, T]{ 58 | when: q, 59 | } 60 | } 61 | 62 | // ScenarioGiven is the state of the scenario once 63 | // a set of Domain Events have been provided using Given(), to represent 64 | // the state of the system at the time of evaluating a Domain Event. 65 | type ScenarioGiven[Q Query, R any, T ProcessorHandler[Q, R]] struct { 66 | given []event.Persisted 67 | } 68 | 69 | // When provides the Command to evaluate. 70 | func (sc ScenarioGiven[Q, R, T]) When(q Envelope[Q]) ScenarioWhen[Q, R, T] { 71 | return ScenarioWhen[Q, R, T]{ 72 | ScenarioGiven: sc, 73 | when: q, 74 | } 75 | } 76 | 77 | // ScenarioWhen is the state of the scenario once the state of the 78 | // system and the Domain Query to evaluate has been provided. 79 | type ScenarioWhen[Q Query, R any, T ProcessorHandler[Q, R]] struct { 80 | ScenarioGiven[Q, R, T] 81 | when Envelope[Q] 82 | } 83 | 84 | // Then sets a positive expectation on the scenario outcome, to produce 85 | // the Query Result provided in input. 86 | func (sc ScenarioWhen[Q, R, T]) Then(result R) ScenarioThen[Q, R, T] { 87 | //nolint:exhaustruct // Zero values are fine here. 88 | return ScenarioThen[Q, R, T]{ 89 | ScenarioWhen: sc, 90 | then: result, 91 | } 92 | } 93 | 94 | // ThenError sets a negative expectation on the scenario outcome, 95 | // to produce an error value that is similar to the one provided in input. 96 | // 97 | // Error assertion happens using errors.Is(), so the error returned 98 | // by the Query Handler is unwrapped until the cause error to match 99 | // the provided expectation. 100 | func (sc ScenarioWhen[Q, R, T]) ThenError(err error) ScenarioThen[Q, R, T] { 101 | //nolint:exhaustruct // Zero values are fine here. 102 | return ScenarioThen[Q, R, T]{ 103 | ScenarioWhen: sc, 104 | wantError: true, 105 | thenError: err, 106 | } 107 | } 108 | 109 | // ThenFails sets a negative expectation on the scenario outcome, 110 | // to fail the Domain Query evaluation with no particular assertion on the error returned. 111 | // 112 | // This is useful when the error returned is not important for the Domain Query 113 | // you're trying to test. 114 | func (sc ScenarioWhen[Q, R, T]) ThenFails() ScenarioThen[Q, R, T] { 115 | //nolint:exhaustruct // Zero values are fine here. 116 | return ScenarioThen[Q, R, T]{ 117 | ScenarioWhen: sc, 118 | wantError: true, 119 | } 120 | } 121 | 122 | // ScenarioThen is the state of the scenario once the preconditions 123 | // and expectations have been fully specified. 124 | type ScenarioThen[Q Query, R any, T ProcessorHandler[Q, R]] struct { 125 | ScenarioWhen[Q, R, T] 126 | 127 | then R 128 | thenError error 129 | wantError bool 130 | } 131 | 132 | // AssertOn performs the specified expectations of the scenario, using the Query Handler 133 | // instance produced by the provided factory function. 134 | func (sc ScenarioThen[Q, R, T]) AssertOn( //nolint:gocritic 135 | t *testing.T, 136 | handlerFactory func(es event.Store) T, 137 | ) { 138 | ctx := context.Background() 139 | 140 | eventStore := event.NewInMemoryStore() 141 | queryHandler := handlerFactory(eventStore) 142 | 143 | for _, evt := range sc.given { 144 | _, err := eventStore.Append(ctx, evt.StreamID, version.CheckExact(evt.Version-1), evt.Envelope) 145 | require.NoError(t, err, "failed to record event on the event store", evt) 146 | 147 | err = queryHandler.Process(ctx, evt) 148 | require.NoError(t, err, "event failed to be processed with the query handler", evt) 149 | } 150 | 151 | actual, err := queryHandler.Handle(ctx, sc.when) 152 | 153 | if !sc.wantError { 154 | assert.NoError(t, err) 155 | assert.Equal(t, sc.then, actual) 156 | 157 | return 158 | } 159 | 160 | if !assert.Error(t, err) { 161 | return 162 | } 163 | 164 | if sc.thenError != nil { 165 | assert.ErrorIs(t, err, sc.thenError) 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /query/scenario_test.go: -------------------------------------------------------------------------------- 1 | package query_test 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/google/uuid" 8 | 9 | "github.com/get-eventually/go-eventually/event" 10 | "github.com/get-eventually/go-eventually/internal/user" 11 | "github.com/get-eventually/go-eventually/query" 12 | ) 13 | 14 | func TestScenario(t *testing.T) { 15 | id := uuid.New() 16 | now := time.Now() 17 | before := now.Add(-1 * time.Minute) 18 | 19 | expected := user.View{ 20 | ID: id, 21 | Version: 1, 22 | Email: "me@email.com", 23 | FirstName: "John", 24 | LastName: "Doe", 25 | BirthDate: time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC), 26 | } 27 | 28 | makeQueryHandler := func(_ event.Store) *user.GetByEmailHandler { 29 | return user.NewGetByEmailHandler() 30 | } 31 | 32 | t.Run("returns the expected User by its email when it was just created", func(t *testing.T) { 33 | query. 34 | Scenario[user.GetByEmail, user.View, *user.GetByEmailHandler](). 35 | Given(event.Persisted{ 36 | StreamID: event.StreamID(id.String()), 37 | Version: 1, 38 | Envelope: event.ToEnvelope(&user.Event{ 39 | ID: id, 40 | RecordTime: before, 41 | Kind: &user.WasCreated{ 42 | FirstName: expected.FirstName, 43 | LastName: expected.LastName, 44 | BirthDate: expected.BirthDate, 45 | Email: expected.Email, 46 | }, 47 | }), 48 | }). 49 | When(query.ToEnvelope(user.GetByEmail(expected.Email))). 50 | Then(expected). 51 | AssertOn(t, makeQueryHandler) 52 | }) 53 | 54 | t.Run("returns the expected User by its email after it has been changed", func(t *testing.T) { 55 | query. 56 | Scenario[user.GetByEmail, user.View, *user.GetByEmailHandler](). 57 | Given(event.Persisted{ 58 | StreamID: event.StreamID(id.String()), 59 | Version: 1, 60 | Envelope: event.ToEnvelope(&user.Event{ 61 | ID: id, 62 | RecordTime: before, 63 | Kind: &user.WasCreated{ 64 | FirstName: expected.FirstName, 65 | LastName: expected.LastName, 66 | BirthDate: expected.BirthDate, 67 | Email: "first@email.com", 68 | }, 69 | }), 70 | }, event.Persisted{ 71 | StreamID: event.StreamID(id.String()), 72 | Version: 2, 73 | Envelope: event.ToEnvelope(&user.Event{ 74 | ID: id, 75 | RecordTime: before, 76 | Kind: &user.EmailWasUpdated{ 77 | Email: expected.Email, 78 | }, 79 | }), 80 | }). 81 | When(query.ToEnvelope(user.GetByEmail(expected.Email))). 82 | Then(expected). 83 | AssertOn(t, makeQueryHandler) 84 | }) 85 | 86 | t.Run("returns user.ErrNotFound if the requested User does not exist", func(t *testing.T) { 87 | query. 88 | Scenario[user.GetByEmail, user.View, *user.GetByEmailHandler](). 89 | Given(). 90 | When(query.ToEnvelope(user.GetByEmail(expected.Email))). 91 | ThenError(user.ErrNotFound). 92 | AssertOn(t, makeQueryHandler) 93 | }) 94 | } 95 | -------------------------------------------------------------------------------- /renovate.json5: -------------------------------------------------------------------------------- 1 | { 2 | $schema: "https://docs.renovatebot.com/renovate-schema.json", 3 | extends: [ 4 | "config:best-practices", 5 | "group:allNonMajor", 6 | "customManagers:githubActionsVersions", 7 | ":pinDependencies", 8 | ], 9 | branchPrefix: "chore/renovate-", 10 | rebaseWhen: "behind-base-branch", 11 | lockFileMaintenance: { 12 | enabled: true, 13 | recreateWhen: "always", 14 | rebaseWhen: "behind-base-branch", 15 | }, 16 | packageRules: [ 17 | { 18 | matchManagers: ["github-actions"], 19 | matchUpdateTypes: ["major", "minor", "patch"], 20 | }, 21 | ], 22 | } 23 | -------------------------------------------------------------------------------- /resources/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/get-eventually/go-eventually/c2b625e2d33e3bdf2a798836b291cc6d91e1b879/resources/logo.png -------------------------------------------------------------------------------- /serde/bytes.go: -------------------------------------------------------------------------------- 1 | package serde 2 | 3 | // BytesSerializer is a specialized Serializer to serialize a Source type 4 | // into a byte array. 5 | type BytesSerializer[Src any] interface { 6 | Serializer[Src, []byte] 7 | } 8 | 9 | // BytesDeserializer is a specialized Deserializer to deserialize a Source type 10 | // from a byte array. 11 | type BytesDeserializer[Src any] interface { 12 | Deserializer[Src, []byte] 13 | } 14 | 15 | // Bytes is a Serde implementation used to serialize a Source type to and 16 | // deserialize it from a byte array. 17 | type Bytes[Src any] interface { 18 | Serde[Src, []byte] 19 | } 20 | -------------------------------------------------------------------------------- /serde/chained.go: -------------------------------------------------------------------------------- 1 | package serde 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | // Chained is a serde type that allows to chain two separate serdes, 8 | // to map from an Src to a Dst type, using a common supporting type in the middle (Mid). 9 | type Chained[Src any, Mid any, Dst any] struct { 10 | first Serde[Src, Mid] 11 | second Serde[Mid, Dst] 12 | } 13 | 14 | // Serialize implements the serde.Serializer interface. 15 | func (s Chained[Src, Mid, Dst]) Serialize(src Src) (Dst, error) { 16 | var zeroValue Dst 17 | 18 | mid, err := s.first.Serialize(src) 19 | if err != nil { 20 | return zeroValue, fmt.Errorf("serde.Chained: first stage serializer failed, %w", err) 21 | } 22 | 23 | dst, err := s.second.Serialize(mid) 24 | if err != nil { 25 | return zeroValue, fmt.Errorf("serde.Chained: second stage serializer failed, %w", err) 26 | } 27 | 28 | return dst, nil 29 | } 30 | 31 | // Deserialize implements the serde.Deserializer interface. 32 | func (s Chained[Src, Mid, Dst]) Deserialize(dst Dst) (Src, error) { 33 | var zeroValue Src 34 | 35 | mid, err := s.second.Deserialize(dst) 36 | if err != nil { 37 | return zeroValue, fmt.Errorf("serde.Chained: first stage deserializer failed, %w", err) 38 | } 39 | 40 | src, err := s.first.Deserialize(mid) 41 | if err != nil { 42 | return zeroValue, fmt.Errorf("serde.Chained: second stage deserializer failed, %w", err) 43 | } 44 | 45 | return src, nil 46 | } 47 | 48 | // Chain chains together two serdes to build a new serde instance to map from Src to Dst types. 49 | func Chain[Src any, Mid any, Dst any](first Serde[Src, Mid], second Serde[Mid, Dst]) Chained[Src, Mid, Dst] { 50 | return Chained[Src, Mid, Dst]{ 51 | first: first, 52 | second: second, 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /serde/chained_test.go: -------------------------------------------------------------------------------- 1 | package serde_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | 8 | "github.com/get-eventually/go-eventually/serde" 9 | ) 10 | 11 | func TestChained(t *testing.T) { 12 | mySerde := serde.Chain( 13 | myDataSerde, 14 | serde.NewJSON(func() *myJSONData { return new(myJSONData) }), 15 | ) 16 | 17 | data := myData{ 18 | Enum: enumFirst, 19 | Something: 1, 20 | Else: "Else", 21 | } 22 | 23 | expected := []byte(`{"enum":"FIRST","something":1,"else":"Else"}`) 24 | 25 | bytes, err := mySerde.Serialize(data) 26 | assert.NoError(t, err) 27 | assert.Equal(t, expected, bytes) 28 | 29 | deserialized, err := mySerde.Deserialize(bytes) 30 | assert.NoError(t, err) 31 | assert.Equal(t, data, deserialized) 32 | } 33 | -------------------------------------------------------------------------------- /serde/doc.go: -------------------------------------------------------------------------------- 1 | // Package serde contains interfaces used for serialization and deserialization 2 | // throughout the eventually library. 3 | package serde 4 | -------------------------------------------------------------------------------- /serde/json.go: -------------------------------------------------------------------------------- 1 | package serde 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | ) 7 | 8 | // NewJSONSerializer returns a serializer function where the input data (Src) 9 | // gets serialized to JSON byte-array data. 10 | func NewJSONSerializer[T any]() SerializerFunc[T, []byte] { 11 | return func(t T) ([]byte, error) { 12 | data, err := json.Marshal(t) 13 | if err != nil { 14 | return nil, fmt.Errorf("serde.JSON: failed to serialize data, %w", err) 15 | } 16 | 17 | return data, nil 18 | } 19 | } 20 | 21 | // NewJSONDeserializer returns a deserializer function where a byte-array 22 | // is deserialized into the specified data type. 23 | // 24 | // A data factory function is required for creating new instances of the type 25 | // (especially if pointer semantics is used). 26 | func NewJSONDeserializer[T any](factory func() T) DeserializerFunc[T, []byte] { 27 | return func(data []byte) (T, error) { 28 | var zeroValue T 29 | 30 | model := factory() 31 | if err := json.Unmarshal(data, &model); err != nil { 32 | return zeroValue, fmt.Errorf("serde.JSON: failed to deserialize data, %w", err) 33 | } 34 | 35 | return model, nil 36 | } 37 | } 38 | 39 | // NewJSON returns a new serde instance where some data (`T`) gets serialized to 40 | // and deserialized from JSON as byte-array. 41 | func NewJSON[T any](factory func() T) Fused[T, []byte] { 42 | return Fuse( 43 | NewJSONSerializer[T](), 44 | NewJSONDeserializer(factory), 45 | ) 46 | } 47 | -------------------------------------------------------------------------------- /serde/json_test.go: -------------------------------------------------------------------------------- 1 | package serde_test 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | 11 | "github.com/get-eventually/go-eventually/serde" 12 | ) 13 | 14 | type myEnum uint8 15 | 16 | const ( 17 | enumFirst myEnum = iota + 1 18 | enumSecond 19 | enumThird 20 | ) 21 | 22 | const ( 23 | enumFirstString = "FIRST" 24 | enumSecondString = "SECOND" 25 | enumThirdString = "THIRD" 26 | ) 27 | 28 | type myData struct { 29 | Enum myEnum 30 | Something int64 31 | Else string 32 | } 33 | 34 | type myJSONData struct { 35 | Enum string `json:"enum"` 36 | Something int64 `json:"something"` 37 | Else string `json:"else"` 38 | } 39 | 40 | func serializeMyData(data myData) (*myJSONData, error) { 41 | jsonData := new(myJSONData) 42 | 43 | switch data.Enum { 44 | case enumFirst: 45 | jsonData.Enum = enumFirstString 46 | case enumSecond: 47 | jsonData.Enum = enumSecondString 48 | case enumThird: 49 | jsonData.Enum = enumThirdString 50 | default: 51 | return nil, fmt.Errorf("failed to serialize data, unexpected data value, %v", data.Enum) 52 | } 53 | 54 | jsonData.Something = data.Something 55 | jsonData.Else = data.Else 56 | 57 | return jsonData, nil 58 | } 59 | 60 | func deserializeMyData(jsonData *myJSONData) (myData, error) { 61 | var data myData 62 | 63 | switch jsonData.Enum { 64 | case enumFirstString: 65 | data.Enum = enumFirst 66 | case enumSecondString: 67 | data.Enum = enumSecond 68 | case enumThirdString: 69 | data.Enum = enumThird 70 | default: 71 | return myData{}, fmt.Errorf("failed to deserialize data, unexpected enum value, %v", jsonData.Enum) 72 | } 73 | 74 | data.Something = jsonData.Something 75 | data.Else = jsonData.Else 76 | 77 | return data, nil 78 | } 79 | 80 | var myDataSerde = serde.Fuse( 81 | serde.AsSerializerFunc(serializeMyData), 82 | serde.AsDeserializerFunc(deserializeMyData), 83 | ) 84 | 85 | func TestJSON(t *testing.T) { 86 | myJSONSerde := serde.NewJSON(func() *myJSONData { return new(myJSONData) }) 87 | 88 | t.Run("it works with valid data", func(t *testing.T) { 89 | myJSON := &myJSONData{ 90 | Enum: "FIRST", 91 | Something: 1, 92 | Else: "Else", 93 | } 94 | 95 | bytes, err := json.Marshal(myJSON) 96 | require.NoError(t, err) 97 | 98 | serialized, err := myJSONSerde.Serialize(myJSON) 99 | assert.NoError(t, err) 100 | assert.Equal(t, bytes, serialized) 101 | 102 | deserialized, err := myJSONSerde.Deserialize(serialized) 103 | assert.NoError(t, err) 104 | assert.Equal(t, myJSON, deserialized) 105 | }) 106 | 107 | t.Run("it fails deserialization of invalid json data", func(t *testing.T) { 108 | deserialized, err := myJSONSerde.Deserialize([]byte("{")) 109 | assert.Error(t, err) 110 | assert.Zero(t, deserialized) 111 | }) 112 | 113 | t.Run("it works also with by-value semantics", func(t *testing.T) { 114 | type byValue struct { 115 | Test bool 116 | } 117 | 118 | mySerde := serde.NewJSON(func() byValue { return byValue{} }) //nolint:exhaustruct // Unnecessary. 119 | myValue := byValue{Test: true} 120 | 121 | serialized, err := mySerde.Serialize(myValue) 122 | assert.NoError(t, err) 123 | assert.NotEmpty(t, serialized) 124 | 125 | deserialized, err := mySerde.Deserialize(serialized) 126 | assert.NoError(t, err) 127 | assert.Equal(t, myValue, deserialized) 128 | }) 129 | } 130 | -------------------------------------------------------------------------------- /serde/proto.go: -------------------------------------------------------------------------------- 1 | package serde 2 | 3 | import ( 4 | "fmt" 5 | 6 | "google.golang.org/protobuf/proto" 7 | ) 8 | 9 | // NewProtoSerializer returns a serializer function where the input data (T) 10 | // gets serialized to Protobuf byte-array. 11 | func NewProtoSerializer[T proto.Message]() SerializerFunc[T, []byte] { 12 | return func(t T) ([]byte, error) { 13 | data, err := proto.Marshal(t) 14 | if err != nil { 15 | return nil, fmt.Errorf("serde.Proto: failed to serialize data, %w", err) 16 | } 17 | 18 | return data, nil 19 | } 20 | } 21 | 22 | // NewProtoDeserializer returns a deserializer function where a byte-array 23 | // is deserialized into a destination data type (T) using Protobuf. 24 | // 25 | // A data factory function is required for creating new instances of type `T` 26 | // (especially if pointer semantics is used). 27 | func NewProtoDeserializer[T proto.Message](factory func() T) DeserializerFunc[T, []byte] { 28 | return func(data []byte) (T, error) { 29 | var zeroValue T 30 | 31 | model := factory() 32 | 33 | if err := proto.Unmarshal(data, model); err != nil { 34 | return zeroValue, fmt.Errorf("serde.Proto: failed to deseruialize data, %w", err) 35 | } 36 | 37 | return model, nil 38 | } 39 | } 40 | 41 | // NewProto returns a new serde instance where some data (`T`) gets serialized to 42 | // and deserialized from a Protobuf byte-array. 43 | func NewProto[T proto.Message](factory func() T) Fused[T, []byte] { 44 | return Fuse( 45 | NewProtoSerializer[T](), 46 | NewProtoDeserializer(factory), 47 | ) 48 | } 49 | -------------------------------------------------------------------------------- /serde/protojson.go: -------------------------------------------------------------------------------- 1 | package serde 2 | 3 | import ( 4 | "fmt" 5 | 6 | "google.golang.org/protobuf/encoding/protojson" 7 | "google.golang.org/protobuf/proto" 8 | ) 9 | 10 | // NewProtoJSONSerializer returns a serializer function where the input data (T) 11 | // gets serialized to Protobuf JSON byte-array data. 12 | func NewProtoJSONSerializer[T proto.Message]() SerializerFunc[T, []byte] { 13 | return func(t T) ([]byte, error) { 14 | data, err := protojson.Marshal(t) 15 | if err != nil { 16 | return nil, fmt.Errorf("serde.ProtoJSON: failed to serialize data, %w", err) 17 | } 18 | 19 | return data, nil 20 | } 21 | } 22 | 23 | // NewProtoJSONDeserializer returns a deserializer function where a byte-array 24 | // is deserialized into a destination model type (T) using Protobuf JSON. 25 | // 26 | // A data factory function is required for creating new instances of type `T` 27 | // (especially if pointer semantics is used). 28 | func NewProtoJSONDeserializer[T proto.Message](factory func() T) DeserializerFunc[T, []byte] { 29 | return func(data []byte) (T, error) { 30 | var zeroValue T 31 | 32 | model := factory() 33 | 34 | if err := protojson.Unmarshal(data, model); err != nil { 35 | return zeroValue, fmt.Errorf("serde.ProtoJSON: failed to deserialize data, %w", err) 36 | } 37 | 38 | return model, nil 39 | } 40 | } 41 | 42 | // NewProtoJSON returns a new serde instance where some data (`T`) gets serialized to 43 | // and deserialized from Protobuf JSON. 44 | func NewProtoJSON[T proto.Message](factory func() T) Fused[T, []byte] { 45 | return Fuse( 46 | NewProtoJSONSerializer[T](), 47 | NewProtoJSONDeserializer(factory), 48 | ) 49 | } 50 | -------------------------------------------------------------------------------- /serde/serde.go: -------------------------------------------------------------------------------- 1 | package serde 2 | 3 | // Serializer is used to serialize a Source type into another Destination type. 4 | type Serializer[Src any, Dst any] interface { 5 | Serialize(src Src) (Dst, error) 6 | } 7 | 8 | // SerializerFunc is a functional implementation of the Serializer interface. 9 | type SerializerFunc[Src any, Dst any] func(src Src) (Dst, error) 10 | 11 | // Serialize implements the serde.Serializer interface. 12 | func (fn SerializerFunc[Src, Dst]) Serialize(src Src) (Dst, error) { return fn(src) } 13 | 14 | // AsSerializerFunc casts the given serialization function into a 15 | // compatible Serializer interface type. 16 | func AsSerializerFunc[Src, Dst any](f func(src Src) (Dst, error)) SerializerFunc[Src, Dst] { 17 | return SerializerFunc[Src, Dst](f) 18 | } 19 | 20 | // AsInfallibleSerializerFunc casts the given infallible serialization function 21 | // into a compatible Serializer interface type. 22 | func AsInfallibleSerializerFunc[Src, Dst any](f func(src Src) Dst) SerializerFunc[Src, Dst] { 23 | return SerializerFunc[Src, Dst](func(src Src) (Dst, error) { 24 | return f(src), nil 25 | }) 26 | } 27 | 28 | // Deserializer is used to deserialize a Source type from another Destination type. 29 | type Deserializer[Src any, Dst any] interface { 30 | Deserialize(dst Dst) (Src, error) 31 | } 32 | 33 | // DeserializerFunc is a functional implementation of the Deserializer interface. 34 | type DeserializerFunc[Src any, Dst any] func(dst Dst) (Src, error) 35 | 36 | // Deserialize implements the serde.Deserializer interface. 37 | func (fn DeserializerFunc[Src, Dst]) Deserialize(dst Dst) (Src, error) { return fn(dst) } 38 | 39 | // AsDeserializerFunc casts the given deserialization function into a 40 | // compatible Deserializer interface type. 41 | func AsDeserializerFunc[Src, Dst any](f func(dst Dst) (Src, error)) DeserializerFunc[Src, Dst] { 42 | return DeserializerFunc[Src, Dst](f) 43 | } 44 | 45 | // AsInfallibleDeserializerFunc casts the given infallible deserialization function 46 | // into a compatible Deserializer interface type. 47 | func AsInfallibleDeserializerFunc[Src, Dst any](f func(dst Dst) Src) DeserializerFunc[Src, Dst] { 48 | return DeserializerFunc[Src, Dst](func(dst Dst) (Src, error) { 49 | return f(dst), nil 50 | }) 51 | } 52 | 53 | // Serde is used to serialize and deserialize from a Source to a Destination type. 54 | type Serde[Src any, Dst any] interface { 55 | Serializer[Src, Dst] 56 | Deserializer[Src, Dst] 57 | } 58 | 59 | // Fused provides a convenient way to fuse together different implementations 60 | // of a Serializer and Deserializer, and use it as a Serde. 61 | type Fused[Src any, Dst any] struct { 62 | Serializer[Src, Dst] 63 | Deserializer[Src, Dst] 64 | } 65 | 66 | // Fuse combines two given Serializer and Deserializer with compatible types 67 | // and returns a Serde implementation through serde.Fused. 68 | func Fuse[Src, Dst any](serializer Serializer[Src, Dst], deserializer Deserializer[Src, Dst]) Fused[Src, Dst] { 69 | return Fused[Src, Dst]{ 70 | Serializer: serializer, 71 | Deserializer: deserializer, 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /sonar-project.properties: -------------------------------------------------------------------------------- 1 | sonar.organization=get-eventually 2 | sonar.projectKey=get-eventually_go-eventually 3 | 4 | sonar.sources=. 5 | sonar.exclusions=**/*_test.go,**/vendor/**,**/testdata/*,**/*.pb.go 6 | 7 | sonar.tests=. 8 | sonar.test.inclusions=**/*_test.go 9 | sonar.go.coverage.reportPaths=coverage.txt 10 | -------------------------------------------------------------------------------- /version/check.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | import "fmt" 4 | 5 | // Any avoids optimistic concurrency checks when requiring a version.Check instance. 6 | var Any = CheckAny{} 7 | 8 | // Check can be used to perform optimistic concurrency checks when writing to 9 | // the Event Store using the event.Appender interface. 10 | type Check interface { 11 | isVersionCheck() 12 | } 13 | 14 | // CheckAny is a Check variant that will avoid optimistic concurrency checks when used. 15 | type CheckAny struct{} 16 | 17 | func (CheckAny) isVersionCheck() {} 18 | 19 | // CheckExact is a Check variant that will ensure the specified version is the current one 20 | // (typically used when needing to check the version of an Event Stream). 21 | type CheckExact Version 22 | 23 | func (CheckExact) isVersionCheck() {} 24 | 25 | // ConflictError is an error returned by an Event Store when appending 26 | // some events using an expected Event Stream version that does not match 27 | // the current state of the Event Stream. 28 | type ConflictError struct { 29 | Expected Version 30 | Actual Version 31 | } 32 | 33 | func (err ConflictError) Error() string { 34 | return fmt.Sprintf( 35 | "version.Check: conflict detected; expected stream version: %d, actual: %d", 36 | err.Expected, 37 | err.Actual, 38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /version/doc.go: -------------------------------------------------------------------------------- 1 | // Package version contains types and utilites to deal with Optimistic Concurrency. 2 | package version 3 | -------------------------------------------------------------------------------- /version/version.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | // Version is the type to specify Event Stream versions. 4 | // Versions should be starting from 1, as they represent the length of a single Event Stream. 5 | type Version uint32 6 | 7 | // SelectFromBeginning is a Selector value that will return all Domain Events in an Event Stream. 8 | var SelectFromBeginning = Selector{From: 0} 9 | 10 | // Selector specifies which slice of the Event Stream to select when streaming Domain Events 11 | // from the Event Store. 12 | type Selector struct { 13 | From Version 14 | } 15 | --------------------------------------------------------------------------------