mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 19:20:23 +01:00
Implement DDD-based architecture with domain, application, infrastructure, and API layers. Includes user/role management with authentication, RBAC permissions, audit logging, Liquibase migrations, and test suite.
595 lines
14 KiB
Markdown
595 lines
14 KiB
Markdown
# Go Implementation Example Session
|
|
|
|
This is an example session showing how the `/ddd-implement` skill works in a Go project.
|
|
|
|
## Scenario
|
|
|
|
You're building a reservation system and need to implement the **Booking** aggregate in the domain layer.
|
|
|
|
---
|
|
|
|
## User Request
|
|
|
|
```
|
|
/ddd-implement --lang=go "Implement Booking aggregate with these requirements:
|
|
- Booking has ID, room ID, guest ID, check-in/check-out dates, and status
|
|
- Can confirm booking (transitions to CONFIRMED)
|
|
- Can cancel booking (transitions to CANCELLED)
|
|
- Cannot modify after checked in
|
|
- Check-out date must be after check-in date"
|
|
```
|
|
|
|
---
|
|
|
|
## Agent Response
|
|
|
|
I'll implement the **Booking aggregate** in the domain layer following DDD patterns for Go.
|
|
|
|
### Analysis
|
|
|
|
**Aggregate Root**: Booking
|
|
**Value Objects**:
|
|
- BookingID (unique identifier)
|
|
- RoomID (reference to Room aggregate)
|
|
- GuestID (reference to Guest aggregate)
|
|
- DateRange (check-in to check-out)
|
|
- BookingStatus (Pending|Confirmed|Cancelled|CheckedIn|CheckedOut)
|
|
|
|
**Invariants**:
|
|
1. Check-out date must be after check-in date
|
|
2. Cannot modify booking after checked in
|
|
3. Can only confirm booking in Pending status
|
|
4. Can only cancel before checked in
|
|
|
|
**Layer**: Domain (`internal/domain/booking`)
|
|
|
|
Let me implement this:
|
|
|
|
### Error Types
|
|
|
|
```go
|
|
package booking
|
|
|
|
import "errors"
|
|
|
|
var (
|
|
ErrInvalidDateRange = errors.New("check-out date must be after check-in date")
|
|
ErrBookingAlreadyConfirmed = errors.New("booking is already confirmed")
|
|
ErrBookingCancelled = errors.New("booking is cancelled")
|
|
ErrBookingCheckedIn = errors.New("cannot modify booking after check-in")
|
|
ErrInvalidStatus = errors.New("invalid status transition")
|
|
)
|
|
|
|
// BookingNotFoundError represents a specific booking not found error
|
|
type BookingNotFoundError struct {
|
|
BookingID BookingID
|
|
}
|
|
|
|
func (e BookingNotFoundError) Error() string {
|
|
return "booking not found: " + e.BookingID.Value()
|
|
}
|
|
```
|
|
|
|
### Value Objects
|
|
|
|
```go
|
|
package booking
|
|
|
|
import (
|
|
"errors"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// BookingID uniquely identifies a booking
|
|
type BookingID struct {
|
|
value string
|
|
}
|
|
|
|
func NewBookingID(value string) (BookingID, error) {
|
|
if value == "" {
|
|
return BookingID{}, errors.New("booking ID cannot be empty")
|
|
}
|
|
return BookingID{value: value}, nil
|
|
}
|
|
|
|
func GenerateBookingID() BookingID {
|
|
return BookingID{value: uuid.New().String()}
|
|
}
|
|
|
|
func (id BookingID) Value() string {
|
|
return id.value
|
|
}
|
|
|
|
func (id BookingID) Equals(other BookingID) bool {
|
|
return id.value == other.value
|
|
}
|
|
|
|
// RoomID references a Room aggregate
|
|
type RoomID struct {
|
|
value string
|
|
}
|
|
|
|
func NewRoomID(value string) (RoomID, error) {
|
|
if value == "" {
|
|
return RoomID{}, errors.New("room ID cannot be empty")
|
|
}
|
|
return RoomID{value: value}, nil
|
|
}
|
|
|
|
func (id RoomID) Value() string {
|
|
return id.value
|
|
}
|
|
|
|
// GuestID references a Guest aggregate
|
|
type GuestID struct {
|
|
value string
|
|
}
|
|
|
|
func NewGuestID(value string) (GuestID, error) {
|
|
if value == "" {
|
|
return GuestID{}, errors.New("guest ID cannot be empty")
|
|
}
|
|
return GuestID{value: value}, nil
|
|
}
|
|
|
|
func (id GuestID) Value() string {
|
|
return id.value
|
|
}
|
|
|
|
// DateRange represents check-in to check-out period
|
|
type DateRange struct {
|
|
checkIn time.Time
|
|
checkOut time.Time
|
|
}
|
|
|
|
// NewDateRange creates a DateRange with validation
|
|
func NewDateRange(checkIn, checkOut time.Time) (DateRange, error) {
|
|
// Invariant: Check-out must be after check-in
|
|
if !checkOut.After(checkIn) {
|
|
return DateRange{}, ErrInvalidDateRange
|
|
}
|
|
|
|
return DateRange{
|
|
checkIn: checkIn,
|
|
checkOut: checkOut,
|
|
}, nil
|
|
}
|
|
|
|
func (dr DateRange) CheckIn() time.Time {
|
|
return dr.checkIn
|
|
}
|
|
|
|
func (dr DateRange) CheckOut() time.Time {
|
|
return dr.checkOut
|
|
}
|
|
|
|
func (dr DateRange) Nights() int {
|
|
duration := dr.checkOut.Sub(dr.checkIn)
|
|
return int(duration.Hours() / 24)
|
|
}
|
|
|
|
func (dr DateRange) Contains(date time.Time) bool {
|
|
return (date.Equal(dr.checkIn) || date.After(dr.checkIn)) &&
|
|
date.Before(dr.checkOut)
|
|
}
|
|
|
|
// BookingStatus represents the current status of a booking
|
|
type BookingStatus int
|
|
|
|
const (
|
|
BookingStatusPending BookingStatus = iota
|
|
BookingStatusConfirmed
|
|
BookingStatusCancelled
|
|
BookingStatusCheckedIn
|
|
BookingStatusCheckedOut
|
|
)
|
|
|
|
func (s BookingStatus) String() string {
|
|
switch s {
|
|
case BookingStatusPending:
|
|
return "PENDING"
|
|
case BookingStatusConfirmed:
|
|
return "CONFIRMED"
|
|
case BookingStatusCancelled:
|
|
return "CANCELLED"
|
|
case BookingStatusCheckedIn:
|
|
return "CHECKED_IN"
|
|
case BookingStatusCheckedOut:
|
|
return "CHECKED_OUT"
|
|
default:
|
|
return "UNKNOWN"
|
|
}
|
|
}
|
|
```
|
|
|
|
### Booking Aggregate
|
|
|
|
```go
|
|
package booking
|
|
|
|
import (
|
|
"time"
|
|
)
|
|
|
|
// Booking aggregate root.
|
|
//
|
|
// Invariants:
|
|
// - Check-out date must be after check-in date
|
|
// - Cannot modify booking after checked in
|
|
// - Can only confirm booking in Pending status
|
|
// - Can only cancel before checked in
|
|
type Booking struct {
|
|
id BookingID
|
|
roomID RoomID
|
|
guestID GuestID
|
|
dateRange DateRange
|
|
status BookingStatus
|
|
|
|
createdAt time.Time
|
|
updatedAt time.Time
|
|
}
|
|
|
|
// NewBooking creates a new booking in Pending status.
|
|
func NewBooking(
|
|
id BookingID,
|
|
roomID RoomID,
|
|
guestID GuestID,
|
|
dateRange DateRange,
|
|
) (*Booking, error) {
|
|
now := time.Now()
|
|
|
|
return &Booking{
|
|
id: id,
|
|
roomID: roomID,
|
|
guestID: guestID,
|
|
dateRange: dateRange,
|
|
status: BookingStatusPending,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
}, nil
|
|
}
|
|
|
|
// Getters
|
|
func (b *Booking) ID() BookingID { return b.id }
|
|
func (b *Booking) RoomID() RoomID { return b.roomID }
|
|
func (b *Booking) GuestID() GuestID { return b.guestID }
|
|
func (b *Booking) DateRange() DateRange { return b.dateRange }
|
|
func (b *Booking) Status() BookingStatus { return b.status }
|
|
func (b *Booking) CreatedAt() time.Time { return b.createdAt }
|
|
func (b *Booking) UpdatedAt() time.Time { return b.updatedAt }
|
|
|
|
// Confirm transitions booking from Pending to Confirmed.
|
|
//
|
|
// Invariant: Can only confirm Pending bookings
|
|
func (b *Booking) Confirm() error {
|
|
// Guard: Check status
|
|
if b.status == BookingStatusConfirmed {
|
|
return ErrBookingAlreadyConfirmed
|
|
}
|
|
if b.status == BookingStatusCancelled {
|
|
return ErrBookingCancelled
|
|
}
|
|
if b.status == BookingStatusCheckedIn || b.status == BookingStatusCheckedOut {
|
|
return ErrBookingCheckedIn
|
|
}
|
|
|
|
// Transition to Confirmed
|
|
b.status = BookingStatusConfirmed
|
|
b.updatedAt = time.Now()
|
|
|
|
return nil
|
|
}
|
|
|
|
// Cancel transitions booking to Cancelled.
|
|
//
|
|
// Invariant: Cannot cancel after checked in
|
|
func (b *Booking) Cancel() error {
|
|
// Guard: Already cancelled
|
|
if b.status == BookingStatusCancelled {
|
|
return nil // Idempotent
|
|
}
|
|
|
|
// Guard: Cannot cancel after check-in
|
|
if b.status == BookingStatusCheckedIn || b.status == BookingStatusCheckedOut {
|
|
return ErrBookingCheckedIn
|
|
}
|
|
|
|
// Transition to Cancelled
|
|
b.status = BookingStatusCancelled
|
|
b.updatedAt = time.Now()
|
|
|
|
return nil
|
|
}
|
|
|
|
// CheckIn transitions booking to CheckedIn status.
|
|
//
|
|
// Invariant: Can only check in Confirmed bookings
|
|
func (b *Booking) CheckIn() error {
|
|
// Guard: Must be confirmed
|
|
if b.status != BookingStatusConfirmed {
|
|
if b.status == BookingStatusCancelled {
|
|
return ErrBookingCancelled
|
|
}
|
|
if b.status == BookingStatusCheckedIn {
|
|
return nil // Idempotent
|
|
}
|
|
return ErrInvalidStatus
|
|
}
|
|
|
|
// Transition to CheckedIn
|
|
b.status = BookingStatusCheckedIn
|
|
b.updatedAt = time.Now()
|
|
|
|
return nil
|
|
}
|
|
|
|
// CheckOut transitions booking to CheckedOut status.
|
|
//
|
|
// Invariant: Can only check out after checked in
|
|
func (b *Booking) CheckOut() error {
|
|
// Guard: Must be checked in
|
|
if b.status != BookingStatusCheckedIn {
|
|
if b.status == BookingStatusCancelled {
|
|
return ErrBookingCancelled
|
|
}
|
|
if b.status == BookingStatusCheckedOut {
|
|
return nil // Idempotent
|
|
}
|
|
return ErrInvalidStatus
|
|
}
|
|
|
|
// Transition to CheckedOut
|
|
b.status = BookingStatusCheckedOut
|
|
b.updatedAt = time.Now()
|
|
|
|
return nil
|
|
}
|
|
|
|
// UpdateDates changes the check-in/check-out dates.
|
|
//
|
|
// Invariant: Cannot modify after checked in
|
|
// Invariant: New dates must be valid (check-out after check-in)
|
|
func (b *Booking) UpdateDates(newDateRange DateRange) error {
|
|
// Guard: Cannot modify after check-in
|
|
if b.status == BookingStatusCheckedIn || b.status == BookingStatusCheckedOut {
|
|
return ErrBookingCheckedIn
|
|
}
|
|
|
|
// Guard: Cannot modify cancelled booking
|
|
if b.status == BookingStatusCancelled {
|
|
return ErrBookingCancelled
|
|
}
|
|
|
|
// Update dates
|
|
b.dateRange = newDateRange
|
|
b.updatedAt = time.Now()
|
|
|
|
return nil
|
|
}
|
|
```
|
|
|
|
### Repository Interface
|
|
|
|
```go
|
|
package booking
|
|
|
|
import (
|
|
"context"
|
|
"time"
|
|
)
|
|
|
|
// Repository defines persistence operations for Booking aggregates.
|
|
type Repository interface {
|
|
// Save persists a booking (insert or update)
|
|
Save(ctx context.Context, booking *Booking) error
|
|
|
|
// FindByID retrieves a booking by ID
|
|
FindByID(ctx context.Context, id BookingID) (*Booking, error)
|
|
|
|
// FindByGuest retrieves all bookings for a guest
|
|
FindByGuest(ctx context.Context, guestID GuestID) ([]*Booking, error)
|
|
|
|
// FindByRoom retrieves all bookings for a room
|
|
FindByRoom(ctx context.Context, roomID RoomID) ([]*Booking, error)
|
|
|
|
// FindOverlapping finds bookings that overlap with the given date range for a room
|
|
FindOverlapping(ctx context.Context, roomID RoomID, dateRange DateRange) ([]*Booking, error)
|
|
|
|
// Delete removes a booking
|
|
Delete(ctx context.Context, id BookingID) error
|
|
}
|
|
```
|
|
|
|
### Domain Service (Optional)
|
|
|
|
```go
|
|
package booking
|
|
|
|
import (
|
|
"context"
|
|
"time"
|
|
)
|
|
|
|
// Service provides domain operations for bookings.
|
|
type Service struct {
|
|
repo Repository
|
|
}
|
|
|
|
// NewService creates a booking service.
|
|
func NewService(repo Repository) *Service {
|
|
return &Service{repo: repo}
|
|
}
|
|
|
|
// CheckAvailability checks if a room is available for the given dates.
|
|
//
|
|
// This is a domain service because it queries across multiple bookings.
|
|
func (s *Service) CheckAvailability(
|
|
ctx context.Context,
|
|
roomID RoomID,
|
|
dateRange DateRange,
|
|
) (bool, error) {
|
|
// Find overlapping bookings
|
|
overlapping, err := s.repo.FindOverlapping(ctx, roomID, dateRange)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
// Check if any active bookings overlap
|
|
for _, booking := range overlapping {
|
|
// Ignore cancelled bookings
|
|
if booking.status == BookingStatusCancelled {
|
|
continue
|
|
}
|
|
|
|
// Found an active booking that overlaps
|
|
return false, nil
|
|
}
|
|
|
|
// Room is available
|
|
return true, nil
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Summary
|
|
|
|
✅ **Implemented**:
|
|
- Booking aggregate root with all required operations
|
|
- Value objects: BookingID, RoomID, GuestID, DateRange, BookingStatus
|
|
- Custom error types for domain-specific errors
|
|
- Repository interface in domain layer
|
|
- Optional domain service for availability checking
|
|
|
|
✅ **DDD Rules Followed**:
|
|
- Aggregate is only entry point
|
|
- All invariants documented in comments
|
|
- Invariants checked in NewBooking and mutation methods
|
|
- No direct references to other aggregates (RoomID, GuestID are IDs only)
|
|
- Pointer receiver for aggregate (*Booking)
|
|
- Value receiver for value objects (DateRange)
|
|
- One aggregate = one transaction boundary
|
|
|
|
✅ **Go Conventions**:
|
|
- Unexported fields, exported methods
|
|
- Constructor function (NewBooking)
|
|
- Error return values (not panics)
|
|
- Sentinel errors (ErrInvalidDateRange, etc.)
|
|
- Custom error types (BookingNotFoundError)
|
|
|
|
✅ **Layer**: Domain (no external dependencies)
|
|
|
|
✅ **Files Created**:
|
|
- `internal/domain/booking/booking.go` - Aggregate root
|
|
- `internal/domain/booking/value_objects.go` - Value objects and IDs
|
|
- `internal/domain/booking/errors.go` - Domain errors
|
|
- `internal/domain/booking/repository.go` - Repository interface
|
|
- `internal/domain/booking/service.go` - Domain service
|
|
|
|
---
|
|
|
|
## Next Steps
|
|
|
|
You can now:
|
|
|
|
1. **Implement the repository**:
|
|
```
|
|
/ddd-implement --lang=go "Implement PostgreSQL repository for Booking aggregate"
|
|
```
|
|
|
|
2. **Implement a use case**:
|
|
```
|
|
/ddd-implement --lang=go "Create CreateBooking use case in application layer"
|
|
```
|
|
|
|
3. **Add tests**:
|
|
```
|
|
Write unit tests for Booking aggregate
|
|
```
|
|
|
|
4. **Implement HTTP handler**:
|
|
```
|
|
/ddd-implement --lang=go "Create HTTP handler for booking endpoints in infrastructure layer"
|
|
```
|
|
|
|
---
|
|
|
|
## Testing Example
|
|
|
|
Here's how you would test the Booking aggregate:
|
|
|
|
```go
|
|
package booking_test
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"myapp/internal/domain/booking"
|
|
)
|
|
|
|
func TestBooking_Confirm_FromPending_Succeeds(t *testing.T) {
|
|
// Arrange
|
|
id := booking.GenerateBookingID()
|
|
roomID, _ := booking.NewRoomID("room-123")
|
|
guestID, _ := booking.NewGuestID("guest-456")
|
|
checkIn := time.Now().AddDate(0, 0, 1)
|
|
checkOut := time.Now().AddDate(0, 0, 3)
|
|
dateRange, _ := booking.NewDateRange(checkIn, checkOut)
|
|
|
|
b, err := booking.NewBooking(id, roomID, guestID, dateRange)
|
|
if err != nil {
|
|
t.Fatalf("NewBooking failed: %v", err)
|
|
}
|
|
|
|
// Act
|
|
err = b.Confirm()
|
|
|
|
// Assert
|
|
if err != nil {
|
|
t.Errorf("Confirm failed: %v", err)
|
|
}
|
|
|
|
if b.Status() != booking.BookingStatusConfirmed {
|
|
t.Errorf("Expected status CONFIRMED, got %v", b.Status())
|
|
}
|
|
}
|
|
|
|
func TestBooking_Cancel_AfterCheckIn_Fails(t *testing.T) {
|
|
// Arrange
|
|
id := booking.GenerateBookingID()
|
|
roomID, _ := booking.NewRoomID("room-123")
|
|
guestID, _ := booking.NewGuestID("guest-456")
|
|
checkIn := time.Now().AddDate(0, 0, 1)
|
|
checkOut := time.Now().AddDate(0, 0, 3)
|
|
dateRange, _ := booking.NewDateRange(checkIn, checkOut)
|
|
|
|
b, _ := booking.NewBooking(id, roomID, guestID, dateRange)
|
|
_ = b.Confirm()
|
|
_ = b.CheckIn()
|
|
|
|
// Act
|
|
err := b.Cancel()
|
|
|
|
// Assert
|
|
if err != booking.ErrBookingCheckedIn {
|
|
t.Errorf("Expected ErrBookingCheckedIn, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDateRange_InvalidDates_Fails(t *testing.T) {
|
|
// Arrange
|
|
checkIn := time.Now().AddDate(0, 0, 3)
|
|
checkOut := time.Now().AddDate(0, 0, 1) // Before check-in!
|
|
|
|
// Act
|
|
_, err := booking.NewDateRange(checkIn, checkOut)
|
|
|
|
// Assert
|
|
if err != booking.ErrInvalidDateRange {
|
|
t.Errorf("Expected ErrInvalidDateRange, got %v", err)
|
|
}
|
|
}
|
|
```
|