├── .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 |

7 |
8 |
9 |
10 |
11 | Domain-driven Design, Event Sourcing and CQRS for Go
12 |
13 |
14 |
15 |
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 |
--------------------------------------------------------------------------------