Skip to content

Commit ee5bd75

Browse files
Merge pull request #2 from Eventuous/feat/booking-sample
feat: add booking sample application
2 parents c7e4815 + b28ab57 commit ee5bd75

15 files changed

Lines changed: 2841 additions & 0 deletions

File tree

docs/superpowers/plans/2026-03-25-booking-sample.md

Lines changed: 1338 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
# Booking Sample Application — Design Spec
2+
3+
A single-service sample application demonstrating Eventuous Go's core capabilities: functional command service, KurrentDB event store, catch-up subscription, and in-memory read model projections.
4+
5+
## Goals
6+
7+
- Show users how to build a complete event-sourced application with Eventuous Go
8+
- Demonstrate the full write → store → subscribe → project cycle
9+
- Use only the functional command service (the primary path)
10+
- Keep it simple enough to read top-to-bottom, rich enough to be useful as a reference
11+
12+
## Module & File Layout
13+
14+
```
15+
samples/booking/
16+
├── go.mod # github.com/eventuous/eventuous-go/samples/booking
17+
├── main.go # Wiring: client, codec, service, read model, subscription, HTTP
18+
├── domain/
19+
│ ├── events.go # Event types + codec registration
20+
│ ├── state.go # BookingState + BookingFold
21+
│ └── commands.go # Command types + handler functions
22+
├── readmodel/
23+
│ └── readmodel.go # In-memory projections + query methods
24+
└── httpapi/
25+
└── api.go # Route registration, handlers, error mapping
26+
```
27+
28+
The module depends on `core` and `kurrentdb`. It is a standalone `go.mod` following the existing multi-module pattern.
29+
30+
## Domain Model
31+
32+
This sample defines its own domain types in `domain/`. It does not import `core/test/testdomain`, which is reserved for framework conformance tests.
33+
34+
### Events
35+
36+
```go
37+
type RoomBooked struct {
38+
BookingID string `json:"bookingId"`
39+
GuestID string `json:"guestId"`
40+
RoomID string `json:"roomId"`
41+
CheckIn string `json:"checkIn"`
42+
CheckOut string `json:"checkOut"`
43+
Price float64 `json:"price"`
44+
Currency string `json:"currency"`
45+
}
46+
47+
type PaymentRecorded struct {
48+
BookingID string `json:"bookingId"`
49+
Amount float64 `json:"amount"`
50+
Currency string `json:"currency"`
51+
PaymentID string `json:"paymentId"`
52+
}
53+
54+
type BookingCancelled struct {
55+
BookingID string `json:"bookingId"`
56+
Reason string `json:"reason"`
57+
}
58+
```
59+
60+
All events registered in a `codec.TypeMap` via a `NewTypeMap()` function in `events.go`. A `NewCodec()` helper constructs the full `codec.Codec` (`codec.NewJSON(NewTypeMap())`), following the same pattern as `testdomain.NewCodec()`.
61+
62+
### State
63+
64+
```go
65+
type BookingState struct {
66+
ID string
67+
GuestID string
68+
RoomID string
69+
CheckIn string
70+
CheckOut string
71+
Price float64
72+
Outstanding float64
73+
Currency string
74+
Paid bool
75+
Cancelled bool
76+
}
77+
```
78+
79+
`BookingFold(state BookingState, event any) BookingState` — type switch over events:
80+
- `RoomBooked`: initializes all fields, `Outstanding = Price`
81+
- `PaymentRecorded`: decrements outstanding, sets `Paid = Outstanding <= 0`
82+
- `BookingCancelled`: sets `Cancelled = true`
83+
84+
### Commands
85+
86+
```go
87+
type BookRoom struct {
88+
BookingID string
89+
GuestID string
90+
RoomID string
91+
CheckIn string
92+
CheckOut string
93+
Price float64
94+
Currency string
95+
}
96+
97+
type RecordPayment struct {
98+
BookingID string
99+
Amount float64
100+
Currency string
101+
PaymentID string
102+
}
103+
104+
type CancelBooking struct {
105+
BookingID string
106+
Reason string
107+
}
108+
```
109+
110+
## Command Service
111+
112+
Single `command.Service[BookingState]` with three registered handlers:
113+
114+
### BookRoom (ExpectedState: IsNew)
115+
- Stream: `Booking-{BookingID}`
116+
- Emits: `RoomBooked` with `Outstanding = Price`
117+
118+
### RecordPayment (ExpectedState: IsExisting)
119+
- Stream: `Booking-{BookingID}`
120+
- Validates: booking not cancelled, not already fully paid
121+
- Emits: `PaymentRecorded`
122+
123+
### CancelBooking (ExpectedState: IsExisting)
124+
- Stream: `Booking-{BookingID}`
125+
- Validates: not already cancelled
126+
- Emits: `BookingCancelled`
127+
128+
All handlers are pure functions: `func(ctx, BookingState, Command) ([]any, error)`.
129+
130+
The HTTP handlers for `RecordPayment` (and others) read updated fields from `result.State` — the write-side state after fold — not from the read model. This avoids eventual consistency issues in the response.
131+
132+
## Read Model
133+
134+
An in-memory `BookingReadModel` struct with `sync.RWMutex` and two projections:
135+
136+
### BookingDetails — `map[string]BookingDocument`
137+
138+
Keyed by booking ID. Full booking view:
139+
140+
```go
141+
type BookingDocument struct {
142+
BookingID string `json:"bookingId"`
143+
GuestID string `json:"guestId"`
144+
RoomID string `json:"roomId"`
145+
CheckIn string `json:"checkIn"`
146+
CheckOut string `json:"checkOut"`
147+
Price float64 `json:"price"`
148+
Outstanding float64 `json:"outstanding"`
149+
Currency string `json:"currency"`
150+
Paid bool `json:"paid"`
151+
Cancelled bool `json:"cancelled"`
152+
}
153+
```
154+
155+
- `RoomBooked` → insert document
156+
- `PaymentRecorded` → update outstanding, set paid if `<= 0`
157+
- `BookingCancelled` → set cancelled flag
158+
159+
Query: `GetBooking(id string) (BookingDocument, bool)`
160+
161+
### MyBookings — `map[string][]BookingSummary`
162+
163+
Keyed by guest ID. List of a guest's bookings:
164+
165+
```go
166+
type BookingSummary struct {
167+
BookingID string `json:"bookingId"`
168+
RoomID string `json:"roomId"`
169+
CheckIn string `json:"checkIn"`
170+
CheckOut string `json:"checkOut"`
171+
Price float64 `json:"price"`
172+
Currency string `json:"currency"`
173+
Cancelled bool `json:"cancelled"`
174+
}
175+
```
176+
177+
- `RoomBooked` → append summary to guest's list
178+
- `BookingCancelled` → looks up `BookingDetails` to find the guest ID, then marks the matching entry as cancelled in the guest's list
179+
180+
Query: `GetGuestBookings(guestID string) []BookingSummary`
181+
182+
### Subscription
183+
184+
KurrentDB catch-up subscription on `$all`. The read model implements `subscription.EventHandler`. The `HandleEvent` implementation uses a type switch on `msg.Payload` and silently ignores `nil` payloads and unknown event types (the `default` case returns `nil`).
185+
186+
Uses `subscription.WithLogging(slog.Default())` middleware, passed via `kurrentdb.WithMiddleware(...)`. No server-side filter is applied — the read model ignores events it doesn't recognize. For production, consider `kurrentdb.WithFilter()` to reduce traffic.
187+
188+
Starts on app boot and tails from the beginning.
189+
190+
## HTTP API
191+
192+
Standard library `net/http` with Go 1.22+ route patterns.
193+
194+
### Endpoints
195+
196+
| Method | Path | Description |
197+
|--------|------|-------------|
198+
| `POST` | `/bookings` | Book a room. Generates UUID for booking ID. Returns `201` with `{bookingId, streamVersion}` |
199+
| `POST` | `/bookings/{id}/payments` | Record a payment. Returns `200` with `{bookingId, outstanding, paid}` |
200+
| `POST` | `/bookings/{id}/cancel` | Cancel a booking. Returns `200` |
201+
| `GET` | `/bookings/{id}` | Get booking details from read model. Returns `200` or `404` |
202+
| `GET` | `/guests/{id}/bookings` | Get guest's bookings from read model. Returns `200` (empty array if none) |
203+
204+
### Error Mapping
205+
206+
| Error | HTTP Status |
207+
|-------|-------------|
208+
| `ErrStreamNotFound` | `404` |
209+
| `ErrOptimisticConcurrency` | `409` |
210+
| `ErrHandlerNotFound` | `400` |
211+
| Validation errors | `422` |
212+
| Everything else | `500` |
213+
214+
A small helper function, not a framework.
215+
216+
### Request/Response Examples
217+
218+
**Book a room:**
219+
```
220+
POST /bookings
221+
{"guestId": "guest-1", "roomId": "room-42", "checkIn": "2026-04-01", "checkOut": "2026-04-05", "price": 500, "currency": "USD"}
222+
223+
201 Created
224+
{"bookingId": "generated-uuid", "streamVersion": 0}
225+
```
226+
227+
**Record payment:**
228+
```
229+
POST /bookings/{id}/payments
230+
{"amount": 200, "currency": "USD", "paymentId": "pay-1"}
231+
232+
200 OK
233+
{"bookingId": "...", "outstanding": 300, "paid": false}
234+
```
235+
236+
## App Wiring (main.go)
237+
238+
1. Read `KURRENTDB_URL` env var (default: `esdb://localhost:2113?tls=false`)
239+
2. Create KurrentDB client
240+
3. Create codec via `domain.NewCodec()`
241+
4. Create KurrentDB store — the same store instance is passed as both reader and writer to `command.New`
242+
5. Create `command.Service[BookingState]`, register all three handlers
243+
6. Create `BookingReadModel`
244+
7. Use `signal.NotifyContext` for graceful shutdown (SIGINT/SIGTERM)
245+
8. Start KurrentDB catch-up subscription in a goroutine with the cancellable context, read model as handler + `WithMiddleware(subscription.WithLogging(slog.Default()))`
246+
9. Register HTTP routes
247+
10. Start `net/http` server on `:8080` — on context cancellation, call `Server.Shutdown` for graceful drain
248+
249+
No config files, no DI container. Explicit wiring.
250+
251+
## What This Does NOT Include
252+
253+
- Aggregate-based command service (optional layer, not the primary path)
254+
- MongoDB or any external read model store
255+
- Multi-service setup (gateway, integration events)
256+
- OpenTelemetry instrumentation (exists in `otel/` module but kept out of the sample for clarity)
257+
- Authentication, middleware, or production concerns

samples/booking/domain/commands.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// Copyright (C) Eventuous HQ OÜ. All rights reserved
2+
// Licensed under the Apache License, Version 2.0.
3+
4+
package domain
5+
6+
// BookRoom creates a new booking.
7+
type BookRoom struct {
8+
BookingID string
9+
GuestID string
10+
RoomID string
11+
CheckIn string
12+
CheckOut string
13+
Price float64
14+
Currency string
15+
}
16+
17+
// RecordPayment records a payment against an existing booking.
18+
type RecordPayment struct {
19+
BookingID string
20+
Amount float64
21+
Currency string
22+
PaymentID string
23+
}
24+
25+
// CancelBooking cancels an existing booking.
26+
type CancelBooking struct {
27+
BookingID string
28+
Reason string
29+
}

samples/booking/domain/events.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// Copyright (C) Eventuous HQ OÜ. All rights reserved
2+
// Licensed under the Apache License, Version 2.0.
3+
4+
package domain
5+
6+
import "github.com/eventuous/eventuous-go/core/codec"
7+
8+
// Events
9+
10+
type RoomBooked struct {
11+
BookingID string `json:"bookingId"`
12+
GuestID string `json:"guestId"`
13+
RoomID string `json:"roomId"`
14+
CheckIn string `json:"checkIn"`
15+
CheckOut string `json:"checkOut"`
16+
Price float64 `json:"price"`
17+
Currency string `json:"currency"`
18+
}
19+
20+
type PaymentRecorded struct {
21+
BookingID string `json:"bookingId"`
22+
Amount float64 `json:"amount"`
23+
Currency string `json:"currency"`
24+
PaymentID string `json:"paymentId"`
25+
}
26+
27+
type BookingCancelled struct {
28+
BookingID string `json:"bookingId"`
29+
Reason string `json:"reason"`
30+
}
31+
32+
// NewTypeMap creates a TypeMap with all booking events registered.
33+
func NewTypeMap() *codec.TypeMap {
34+
tm := codec.NewTypeMap()
35+
mustRegister[RoomBooked](tm, "RoomBooked")
36+
mustRegister[PaymentRecorded](tm, "PaymentRecorded")
37+
mustRegister[BookingCancelled](tm, "BookingCancelled")
38+
return tm
39+
}
40+
41+
// NewCodec creates a JSON codec with all booking events registered.
42+
func NewCodec() codec.Codec {
43+
return codec.NewJSON(NewTypeMap())
44+
}
45+
46+
func mustRegister[E any](tm *codec.TypeMap, name string) {
47+
if err := codec.Register[E](tm, name); err != nil {
48+
panic(err)
49+
}
50+
}

0 commit comments

Comments
 (0)