|
| 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 |
0 commit comments