mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 21:19:58 +01:00
14 KiB
14 KiB
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:
- 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
Layer: Domain (internal/domain/booking)
Let me implement this:
Error Types
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
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
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
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)
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 rootinternal/domain/booking/value_objects.go- Value objects and IDsinternal/domain/booking/errors.go- Domain errorsinternal/domain/booking/repository.go- Repository interfaceinternal/domain/booking/service.go- Domain service
Next Steps
You can now:
-
Implement the repository:
/ddd-implement --lang=go "Implement PostgreSQL repository for Booking aggregate" -
Implement a use case:
/ddd-implement --lang=go "Create CreateBooking use case in application layer" -
Add tests:
Write unit tests for Booking aggregate -
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:
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)
}
}