1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 15:49:35 +01:00
effigenix/.claude/skills/ddd-implement/examples/go-example.md
Sebastian Frick ec9114aa0a feat: add Spring Boot ERP application with user management domain
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.
2026-02-17 19:33:24 +01:00

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:

  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

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 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:

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)
    }
}