mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 13:49:36 +01:00
docs: and skills
This commit is contained in:
parent
e4f0665086
commit
ccd4ee534a
25 changed files with 10412 additions and 0 deletions
595
bin/.claude/skills/ddd-implement/examples/go-example.md
Normal file
595
bin/.claude/skills/ddd-implement/examples/go-example.md
Normal file
|
|
@ -0,0 +1,595 @@
|
|||
# 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)
|
||||
}
|
||||
}
|
||||
```
|
||||
Loading…
Add table
Add a link
Reference in a new issue