Skip to content

Commit bcf46d2

Browse files
alexeyzimarevclaude
andcommitted
feat(command): align Result with .NET Eventuous, add Change type with event type names
Replace Result.NewEvents ([]any) with Result.Changes ([]Change) where Change pairs each event with its registered TypeMap name. Both Service and AggregateService now accept *codec.TypeMap so they can resolve type names when building the result — matching the .NET Result<TState>.Ok shape (state, changes, globalPosition). StreamVersion is kept in Go and will be added to .NET later. Standardize booking sample HTTP API to return the same JSON envelope from all command endpoints, enabling UI portability between Go and .NET backends. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ee5bd75 commit bcf46d2

File tree

11 files changed

+124
-66
lines changed

11 files changed

+124
-66
lines changed

core/command/aggservice.go

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99

1010
eventuous "github.com/eventuous/eventuous-go/core"
1111
"github.com/eventuous/eventuous-go/core/aggregate"
12+
"github.com/eventuous/eventuous-go/core/codec"
1213
"github.com/eventuous/eventuous-go/core/store"
1314
)
1415

@@ -17,6 +18,7 @@ import (
1718
type AggregateService[S any] struct {
1819
reader store.EventReader
1920
writer store.EventWriter
21+
typeMap *codec.TypeMap
2022
fold func(S, any) S
2123
zero S
2224
handlers map[reflect.Type]untypedAggHandler[S]
@@ -33,12 +35,14 @@ type untypedAggHandler[S any] struct {
3335
func NewAggregateService[S any](
3436
reader store.EventReader,
3537
writer store.EventWriter,
38+
typeMap *codec.TypeMap,
3639
fold func(S, any) S,
3740
zero S,
3841
) *AggregateService[S] {
3942
return &AggregateService[S]{
4043
reader: reader,
4144
writer: writer,
45+
typeMap: typeMap,
4246
fold: fold,
4347
zero: zero,
4448
handlers: make(map[reflect.Type]untypedAggHandler[S]),
@@ -97,11 +101,11 @@ func (svc *AggregateService[S]) Handle(ctx context.Context, command any) (*Resul
97101
}
98102

99103
// Step 7: If no changes, return current state (no-op).
100-
changes := agg.Changes()
101-
if len(changes) == 0 {
104+
rawChanges := agg.Changes()
105+
if len(rawChanges) == 0 {
102106
return &Result[S]{
103107
State: agg.State(),
104-
NewEvents: nil,
108+
Changes: nil,
105109
StreamVersion: agg.OriginalVersion(),
106110
}, nil
107111
}
@@ -112,10 +116,16 @@ func (svc *AggregateService[S]) Handle(ctx context.Context, command any) (*Resul
112116
return nil, err
113117
}
114118

115-
// Step 9: Return result.
119+
// Step 9: Build typed changes and return result.
120+
changes := make([]Change, len(rawChanges))
121+
for i, e := range rawChanges {
122+
typeName, _ := svc.typeMap.TypeName(e)
123+
changes[i] = Change{Event: e, EventType: typeName}
124+
}
125+
116126
return &Result[S]{
117127
State: agg.State(),
118-
NewEvents: changes,
128+
Changes: changes,
119129
GlobalPosition: appendResult.GlobalPosition,
120130
StreamVersion: appendResult.NextExpectedVersion,
121131
}, nil

core/command/aggservice_test.go

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import (
1616
)
1717

1818
func newAggService(s *memstore.Store) *command.AggregateService[testdomain.BookingState] {
19-
return command.NewAggregateService[testdomain.BookingState](s, s, testdomain.BookingFold, testdomain.BookingState{})
19+
return command.NewAggregateService[testdomain.BookingState](s, s, testdomain.NewTypeMap(), testdomain.BookingFold, testdomain.BookingState{})
2020
}
2121

2222
// aggStreamName returns the stream name as the AggregateService generates it.
@@ -79,8 +79,8 @@ func TestAggService_OnNew_Success(t *testing.T) {
7979
if result == nil {
8080
t.Fatal("expected non-nil result")
8181
}
82-
if len(result.NewEvents) != 1 {
83-
t.Fatalf("expected 1 new event, got %d", len(result.NewEvents))
82+
if len(result.Changes) != 1 {
83+
t.Fatalf("expected 1 new event, got %d", len(result.Changes))
8484
}
8585
if result.State.RoomID != "room-42" {
8686
t.Errorf("expected RoomID=room-42, got %s", result.State.RoomID)
@@ -127,8 +127,8 @@ func TestAggService_OnExisting_Success(t *testing.T) {
127127
if result.State.AmountPaid != 100.0 {
128128
t.Errorf("expected AmountPaid=100.0, got %f", result.State.AmountPaid)
129129
}
130-
if len(result.NewEvents) != 1 {
131-
t.Fatalf("expected 1 new event, got %d", len(result.NewEvents))
130+
if len(result.Changes) != 1 {
131+
t.Fatalf("expected 1 new event, got %d", len(result.Changes))
132132
}
133133
}
134134

@@ -164,8 +164,8 @@ func TestAggService_OnAny_Works(t *testing.T) {
164164
if result.State.RoomID != "room-5" {
165165
t.Errorf("expected RoomID=room-5, got %s", result.State.RoomID)
166166
}
167-
if len(result.NewEvents) != 1 {
168-
t.Fatalf("expected 1 new event, got %d", len(result.NewEvents))
167+
if len(result.Changes) != 1 {
168+
t.Fatalf("expected 1 new event, got %d", len(result.Changes))
169169
}
170170
})
171171

@@ -183,8 +183,8 @@ func TestAggService_OnAny_Works(t *testing.T) {
183183
if err != nil {
184184
t.Fatalf("unexpected error: %v", err)
185185
}
186-
if len(result.NewEvents) != 1 {
187-
t.Fatalf("expected 1 new event, got %d", len(result.NewEvents))
186+
if len(result.Changes) != 1 {
187+
t.Fatalf("expected 1 new event, got %d", len(result.Changes))
188188
}
189189
})
190190
}
@@ -225,8 +225,8 @@ func TestAggService_NoOp(t *testing.T) {
225225
if err != nil {
226226
t.Fatalf("unexpected error: %v", err)
227227
}
228-
if len(result.NewEvents) != 0 {
229-
t.Errorf("expected 0 new events, got %d", len(result.NewEvents))
228+
if len(result.Changes) != 0 {
229+
t.Errorf("expected 0 new events, got %d", len(result.Changes))
230230
}
231231
// Verify nothing was appended.
232232
exists, _ := s.StreamExists(context.Background(), aggStreamName("noop-agg-booking"))

core/command/result.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,16 @@
33

44
package command
55

6+
// Change pairs a domain event with its registered type name.
7+
type Change struct {
8+
Event any
9+
EventType string
10+
}
11+
612
// Result of a handled command.
713
type Result[S any] struct {
814
State S
9-
NewEvents []any
15+
Changes []Change
1016
GlobalPosition uint64
1117
StreamVersion int64
1218
}

core/command/service.go

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"reflect"
99

1010
eventuous "github.com/eventuous/eventuous-go/core"
11+
"github.com/eventuous/eventuous-go/core/codec"
1112
"github.com/eventuous/eventuous-go/core/store"
1213
"github.com/google/uuid"
1314
)
@@ -21,6 +22,7 @@ type CommandHandler[S any] interface {
2122
type Service[S any] struct {
2223
reader store.EventReader
2324
writer store.EventWriter
25+
typeMap *codec.TypeMap
2426
fold func(S, any) S
2527
zero S
2628
handlers map[reflect.Type]untypedHandler[S]
@@ -30,12 +32,14 @@ type Service[S any] struct {
3032
func New[S any](
3133
reader store.EventReader,
3234
writer store.EventWriter,
35+
typeMap *codec.TypeMap,
3336
fold func(S, any) S,
3437
zero S,
3538
) *Service[S] {
3639
return &Service[S]{
3740
reader: reader,
3841
writer: writer,
42+
typeMap: typeMap,
3943
fold: fold,
4044
zero: zero,
4145
handlers: make(map[reflect.Type]untypedHandler[S]),
@@ -79,7 +83,7 @@ func (svc *Service[S]) Handle(ctx context.Context, command any) (*Result[S], err
7983
if len(newEvents) == 0 {
8084
return &Result[S]{
8185
State: state,
82-
NewEvents: nil,
86+
Changes: nil,
8387
StreamVersion: int64(version),
8488
}, nil
8589
}
@@ -98,15 +102,18 @@ func (svc *Service[S]) Handle(ctx context.Context, command any) (*Result[S], err
98102
return nil, err
99103
}
100104

101-
// Step 7: Fold new events into state for the result.
102-
for _, e := range newEvents {
105+
// Step 7: Build changes and fold new events into state.
106+
changes := make([]Change, len(newEvents))
107+
for i, e := range newEvents {
108+
typeName, _ := svc.typeMap.TypeName(e)
109+
changes[i] = Change{Event: e, EventType: typeName}
103110
state = svc.fold(state, e)
104111
}
105112

106113
// Step 8: Return Result[S].
107114
return &Result[S]{
108115
State: state,
109-
NewEvents: newEvents,
116+
Changes: changes,
110117
GlobalPosition: appendResult.GlobalPosition,
111118
StreamVersion: appendResult.NextExpectedVersion,
112119
}, nil

core/command/service_test.go

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import (
1717
)
1818

1919
func newService(s *memstore.Store) *command.Service[testdomain.BookingState] {
20-
return command.New[testdomain.BookingState](s, s, testdomain.BookingFold, testdomain.BookingState{})
20+
return command.New[testdomain.BookingState](s, s, testdomain.NewTypeMap(), testdomain.BookingFold, testdomain.BookingState{})
2121
}
2222

2323
// seedEvents directly appends raw events to the memstore for test setup.
@@ -82,8 +82,8 @@ func TestService_OnNew_Success(t *testing.T) {
8282
if result == nil {
8383
t.Fatal("expected non-nil result")
8484
}
85-
if len(result.NewEvents) != 1 {
86-
t.Fatalf("expected 1 new event, got %d", len(result.NewEvents))
85+
if len(result.Changes) != 1 {
86+
t.Fatalf("expected 1 new event, got %d", len(result.Changes))
8787
}
8888
if result.State.RoomID != "room-42" {
8989
t.Errorf("expected RoomID=room-42, got %s", result.State.RoomID)
@@ -126,8 +126,8 @@ func TestService_OnExisting_Success(t *testing.T) {
126126
if result.State.AmountPaid != 100.0 {
127127
t.Errorf("expected AmountPaid=100.0, got %f", result.State.AmountPaid)
128128
}
129-
if len(result.NewEvents) != 1 {
130-
t.Fatalf("expected 1 new event, got %d", len(result.NewEvents))
129+
if len(result.Changes) != 1 {
130+
t.Fatalf("expected 1 new event, got %d", len(result.Changes))
131131
}
132132
}
133133

@@ -176,8 +176,8 @@ func TestService_OnAny_ExistingStream(t *testing.T) {
176176
if err != nil {
177177
t.Fatalf("unexpected error: %v", err)
178178
}
179-
if len(result.NewEvents) != 1 {
180-
t.Fatalf("expected 1 new event, got %d", len(result.NewEvents))
179+
if len(result.Changes) != 1 {
180+
t.Fatalf("expected 1 new event, got %d", len(result.Changes))
181181
}
182182
}
183183

@@ -216,8 +216,8 @@ func TestService_NoOp(t *testing.T) {
216216
if err != nil {
217217
t.Fatalf("unexpected error: %v", err)
218218
}
219-
if len(result.NewEvents) != 0 {
220-
t.Errorf("expected 0 new events, got %d", len(result.NewEvents))
219+
if len(result.Changes) != 0 {
220+
t.Errorf("expected 0 new events, got %d", len(result.Changes))
221221
}
222222
// Verify nothing was appended.
223223
exists, _ := s.StreamExists(context.Background(), testdomain.BookingStream("noop-booking"))

kurrentdb/e2e_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ func TestEndToEnd(t *testing.T) {
2929
bookingID := "e2e-booking-" + uuid.New().String()[:8]
3030

3131
// 2. Create functional command service with the booking fold.
32-
svc := command.New[testdomain.BookingState](store, store, testdomain.BookingFold, testdomain.BookingState{})
32+
svc := command.New[testdomain.BookingState](store, store, testdomain.NewTypeMap(), testdomain.BookingFold, testdomain.BookingState{})
3333

3434
// Register BookRoom handler (stream must be new).
3535
command.On(svc, command.Handler[testdomain.BookRoom, testdomain.BookingState]{
@@ -84,8 +84,8 @@ func TestEndToEnd(t *testing.T) {
8484
if !result.State.Active {
8585
t.Error("expected Active=true after BookRoom")
8686
}
87-
if len(result.NewEvents) != 1 {
88-
t.Errorf("expected 1 new event from BookRoom, got %d", len(result.NewEvents))
87+
if len(result.Changes) != 1 {
88+
t.Errorf("expected 1 new event from BookRoom, got %d", len(result.Changes))
8989
}
9090

9191
// 4. Handle RecordPayment command.

samples/booking/domain/events.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@ func NewCodec() codec.Codec {
4343
return codec.NewJSON(NewTypeMap())
4444
}
4545

46+
// NewCodecFromTypeMap creates a JSON codec from an existing TypeMap.
47+
func NewCodecFromTypeMap(tm *codec.TypeMap) codec.Codec {
48+
return codec.NewJSON(tm)
49+
}
50+
4651
func mustRegister[E any](tm *codec.TypeMap, name string) {
4752
if err := codec.Register[E](tm, name); err != nil {
4853
panic(err)

samples/booking/domain/state.go

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,16 @@ package domain
55

66
// BookingState is the write-side state reconstructed by folding events.
77
type BookingState struct {
8-
ID string
9-
GuestID string
10-
RoomID string
11-
CheckIn string
12-
CheckOut string
13-
Price float64
14-
Outstanding float64
15-
Currency string
16-
Paid bool
17-
Cancelled bool
8+
ID string `json:"id"`
9+
GuestID string `json:"guestId"`
10+
RoomID string `json:"roomId"`
11+
CheckIn string `json:"checkIn"`
12+
CheckOut string `json:"checkOut"`
13+
Price float64 `json:"price"`
14+
Outstanding float64 `json:"outstanding"`
15+
Currency string `json:"currency"`
16+
Paid bool `json:"paid"`
17+
Cancelled bool `json:"cancelled"`
1818
}
1919

2020
// BookingFold is the fold function used by the command service to reconstruct state.

samples/booking/httpapi/api.go

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -60,10 +60,7 @@ func handleBookRoom(svc command.CommandHandler[domain.BookingState]) http.Handle
6060
return
6161
}
6262

63-
writeJSON(w, http.StatusCreated, map[string]any{
64-
"bookingId": bookingID,
65-
"streamVersion": result.StreamVersion,
66-
})
63+
writeJSON(w, http.StatusCreated, newCommandResponse(result))
6764
}
6865
}
6966

@@ -93,11 +90,7 @@ func handleRecordPayment(svc command.CommandHandler[domain.BookingState]) http.H
9390
return
9491
}
9592

96-
writeJSON(w, http.StatusOK, map[string]any{
97-
"bookingId": bookingID,
98-
"outstanding": result.State.Outstanding,
99-
"paid": result.State.Paid,
100-
})
93+
writeJSON(w, http.StatusOK, newCommandResponse(result))
10194
}
10295
}
10396

@@ -114,7 +107,7 @@ func handleCancelBooking(svc command.CommandHandler[domain.BookingState]) http.H
114107
return
115108
}
116109

117-
_, err := svc.Handle(r.Context(), domain.CancelBooking{
110+
result, err := svc.Handle(r.Context(), domain.CancelBooking{
118111
BookingID: bookingID,
119112
Reason: req.Reason,
120113
})
@@ -123,7 +116,7 @@ func handleCancelBooking(svc command.CommandHandler[domain.BookingState]) http.H
123116
return
124117
}
125118

126-
w.WriteHeader(http.StatusOK)
119+
writeJSON(w, http.StatusOK, newCommandResponse(result))
127120
}
128121
}
129122

@@ -150,6 +143,33 @@ func handleGetGuestBookings(rm *readmodel.BookingReadModel) http.HandlerFunc {
150143
}
151144
}
152145

146+
// commandResponse is the standard JSON envelope for command results,
147+
// matching the Eventuous .NET Result<TState>.Ok shape.
148+
type commandResponse struct {
149+
State any `json:"state"`
150+
Changes []changeResponse `json:"changes"`
151+
GlobalPosition uint64 `json:"globalPosition"`
152+
StreamVersion int64 `json:"streamVersion"`
153+
}
154+
155+
type changeResponse struct {
156+
Event any `json:"event"`
157+
EventType string `json:"eventType"`
158+
}
159+
160+
func newCommandResponse[S any](result *command.Result[S]) commandResponse {
161+
changes := make([]changeResponse, len(result.Changes))
162+
for i, c := range result.Changes {
163+
changes[i] = changeResponse{Event: c.Event, EventType: c.EventType}
164+
}
165+
return commandResponse{
166+
State: result.State,
167+
Changes: changes,
168+
GlobalPosition: result.GlobalPosition,
169+
StreamVersion: result.StreamVersion,
170+
}
171+
}
172+
153173
// writeJSON encodes v as JSON and writes it with the given status code.
154174
func writeJSON(w http.ResponseWriter, status int, v any) {
155175
w.Header().Set("Content-Type", "application/json")

0 commit comments

Comments
 (0)