From ccd4ee534acb3c85765876456ee693fd03ddfb38 Mon Sep 17 00:00:00 2001 From: Sebastian Frick Date: Wed, 18 Feb 2026 23:25:12 +0100 Subject: [PATCH] docs: and skills --- bin/.claude/skills/ddd-implement/README.md | 188 +++++ bin/.claude/skills/ddd-implement/SKILL.md | 66 ++ .../ddd-implement/examples/go-example.md | 595 +++++++++++++ .../ddd-implement/examples/java-example.md | 637 ++++++++++++++ .../skills/ddd-implement/system-prompt.md | 787 ++++++++++++++++++ bin/.claude/skills/ddd-model/SKILL.md | 172 ++++ .../skills/ddd-model/examples/banking-go.md | 633 ++++++++++++++ .../languages/java/error-handling.md | 781 +++++++++++++++++ .../ddd-model/languages/java/structure.md | 685 +++++++++++++++ .../ddd-model/languages/java/style-guide.md | 767 +++++++++++++++++ .../java/templates/Aggregate.java.md | 687 +++++++++++++++ .../languages/java/templates/Entity.java.md | 600 +++++++++++++ .../java/templates/Repository.java.md | 721 ++++++++++++++++ .../languages/java/templates/UseCase.java.md | 772 +++++++++++++++++ .../java/templates/ValueObject.java.md | 751 +++++++++++++++++ .../skills/ddd-model/rules/clean-arch.md | 265 ++++++ .../skills/ddd-model/rules/ddd-rules.md | 184 ++++ .../ddd-model/rules/degraded-state-pattern.md | 178 ++++ .../skills/ddd-model/rules/error-handling.md | 47 ++ .../skills/ddd-model/rules/invariants.md | 282 +++++++ bin/.claude/skills/ddd-model/workflow.md | 348 ++++++++ bin/.gitignore | 65 ++ bin/CLAUDE.md | 73 ++ bin/README.md | 115 +++ bin/TODO.md | 13 + 25 files changed, 10412 insertions(+) create mode 100644 bin/.claude/skills/ddd-implement/README.md create mode 100644 bin/.claude/skills/ddd-implement/SKILL.md create mode 100644 bin/.claude/skills/ddd-implement/examples/go-example.md create mode 100644 bin/.claude/skills/ddd-implement/examples/java-example.md create mode 100644 bin/.claude/skills/ddd-implement/system-prompt.md create mode 100644 bin/.claude/skills/ddd-model/SKILL.md create mode 100644 bin/.claude/skills/ddd-model/examples/banking-go.md create mode 100644 bin/.claude/skills/ddd-model/languages/java/error-handling.md create mode 100644 bin/.claude/skills/ddd-model/languages/java/structure.md create mode 100644 bin/.claude/skills/ddd-model/languages/java/style-guide.md create mode 100644 bin/.claude/skills/ddd-model/languages/java/templates/Aggregate.java.md create mode 100644 bin/.claude/skills/ddd-model/languages/java/templates/Entity.java.md create mode 100644 bin/.claude/skills/ddd-model/languages/java/templates/Repository.java.md create mode 100644 bin/.claude/skills/ddd-model/languages/java/templates/UseCase.java.md create mode 100644 bin/.claude/skills/ddd-model/languages/java/templates/ValueObject.java.md create mode 100644 bin/.claude/skills/ddd-model/rules/clean-arch.md create mode 100644 bin/.claude/skills/ddd-model/rules/ddd-rules.md create mode 100644 bin/.claude/skills/ddd-model/rules/degraded-state-pattern.md create mode 100644 bin/.claude/skills/ddd-model/rules/error-handling.md create mode 100644 bin/.claude/skills/ddd-model/rules/invariants.md create mode 100644 bin/.claude/skills/ddd-model/workflow.md create mode 100644 bin/.gitignore create mode 100644 bin/CLAUDE.md create mode 100644 bin/README.md create mode 100644 bin/TODO.md diff --git a/bin/.claude/skills/ddd-implement/README.md b/bin/.claude/skills/ddd-implement/README.md new file mode 100644 index 0000000..e22a745 --- /dev/null +++ b/bin/.claude/skills/ddd-implement/README.md @@ -0,0 +1,188 @@ +# DDD Implementation Skill + +A Claude Code skill that acts as a Senior DDD Developer, implementing domain-driven code following Clean Architecture principles. + +## What This Skill Does + +This skill helps you implement DDD code that follows established patterns and rules: + +✅ **Aggregates** - With proper invariant enforcement and Result types +✅ **Entities** - Child entities within aggregates +✅ **Value Objects** - Immutable, self-validating +✅ **Use Cases** - In application layer with proper error handling +✅ **Repositories** - Interfaces in domain, implementations in infrastructure +✅ **Domain Events** - For cross-aggregate communication + +## When to Use This Skill + +**Use `/ddd-implement` when**: +- You have a domain model designed (from `/ddd-model`) +- You need to implement specific aggregates, entities, or value objects +- You want code that follows DDD rules automatically +- You need proper error handling (Result types for Java, errors for Go) +- You want layer boundaries respected + +**Don't use this skill for**: +- Domain modeling and design (use `/ddd-model` instead) +- Generic coding tasks (use default Claude Code) +- Non-DDD projects + +## Usage Examples + +### Implement an Aggregate + +```bash +/ddd-implement --lang=java "Implement Order aggregate with addLineItem, removeLineItem, and cancel methods" +``` + +The skill will: +1. Create the aggregate in `domain/order/` +2. Add proper invariants (e.g., "Cannot modify cancelled order") +3. Use Result types for error handling +4. Implement sealed interfaces for errors +5. Add domain events (OrderCreated, OrderCancelled, etc.) + +### Implement a Use Case + +```bash +/ddd-implement --lang=go "Implement PlaceOrder use case that creates an order and reserves inventory" +``` + +The skill will: +1. Create use case in `application/order/` +2. Use repository interfaces (not implementations) +3. Handle errors properly +4. Return DTOs (not domain objects) +5. Add transaction boundaries if needed + +### Implement a Repository + +```bash +/ddd-implement --lang=java "Implement PostgreSQL repository for Order aggregate" +``` + +The skill will: +1. Create implementation in `infrastructure/order/persistence/` +2. Implement the domain interface +3. Add exception boundary (catch SQL exceptions → return domain errors) +4. Map between domain model and database schema + +### Implement from Existing File + +```bash +/ddd-implement internal/domain/account/account.go +``` + +The skill will: +1. Detect language from file extension +2. Read existing code +3. Suggest improvements or complete partial implementations +4. Follow established patterns in the file + +## What Makes This Skill Different + +### Enforces DDD Rules Automatically + +The skill knows and enforces: +- Aggregate boundaries (no direct aggregate-to-aggregate references) +- Invariant documentation and enforcement +- Entity equality (ID-based only) +- Value Object immutability +- Repository patterns (interface in domain, impl in infrastructure) +- Layer dependencies (domain has no external deps) + +### Language-Aware + +**For Java**: +- Uses Result types (no exceptions from domain/application) +- Uses sealed interfaces for errors +- Uses pattern matching with switch expressions +- Uses static imports for Failure/Success +- Follows Java 21+ conventions + +**For Go**: +- Uses error return values +- Uses pointer receivers for aggregates/entities +- Uses value receivers for value objects +- Uses sentinel errors and custom error types +- Follows Uber Go Style Guide + +### Error Handling Built-In + +The skill automatically: +- Returns Result types (Java) or errors (Go) +- Creates layer-specific error types +- Adds exception boundaries at infrastructure layer +- Logs errors appropriately (ERROR/WARN/INFO levels) +- Prevents silent failures + +## How It Works + +1. **Analyzes your request** - Determines what to implement (aggregate, use case, etc.) +2. **Detects language** - From flags, file extensions, or project structure +3. **Loads rules** - DDD rules, error handling, style guides for your language +4. **Generates code** - Following templates and patterns +5. **Validates** - Checks against DDD rules before completion + +## Architecture + +``` +/ddd-implement +├── SKILL.md # Skill manifest (loaded by Claude Code) +├── system-prompt.md # Core instructions for the implementation agent +├── README.md # This file +└── examples/ + ├── go-example.md # Example session in Go + └── java-example.md # Example session in Java +``` + +The `system-prompt.md` references rules from the `ddd-model` skill: +- `ddd-model/rules/ddd-rules.md` - Core DDD patterns +- `ddd-model/rules/error-handling.md` - Error handling strategy +- `ddd-model/languages/{lang}/style-guide.md` - Language conventions +- `ddd-model/languages/{lang}/templates/` - Code templates + +## Workflow: Modeling → Implementation + +``` +1. Design with /ddd-model + ↓ (identifies aggregates, entities, invariants) + +2. Implement with /ddd-implement + ↓ (generates code following rules) + +3. Review with /review (or code review) + ↓ (validates DDD principles) + +4. Iterate +``` + +## Examples + +See detailed examples in: +- [Java Example Session](./examples/java-example.md) +- [Go Example Session](./examples/go-example.md) + +## Rules Reference + +This skill enforces rules from: + +- **DDD Rules**: [ddd-model/rules/ddd-rules.md](../ddd-model/rules/ddd-rules.md) +- **Clean Architecture**: [ddd-model/rules/clean-arch.md](../ddd-model/rules/clean-arch.md) +- **Error Handling**: [ddd-model/rules/error-handling.md](../ddd-model/rules/error-handling.md) +- **Java Style**: [ddd-model/languages/java/style-guide.md](../ddd-model/languages/java/style-guide.md) +- **Go Style**: [ddd-model/languages/go/style-guide.md](../ddd-model/languages/go/style-guide.md) + +## Contributing + +To improve this skill: + +1. **Add examples** - Real-world implementation sessions in `examples/` +2. **Refine rules** - Update `system-prompt.md` based on experience +3. **Add templates** - Language-specific templates in `ddd-model/languages/{lang}/templates/` +4. **Document patterns** - Special patterns in `ddd-model/rules/` + +## Related Skills + +- **ddd-model** - For domain modeling and design +- **review** - For code review with DDD principles (if available) diff --git a/bin/.claude/skills/ddd-implement/SKILL.md b/bin/.claude/skills/ddd-implement/SKILL.md new file mode 100644 index 0000000..736af76 --- /dev/null +++ b/bin/.claude/skills/ddd-implement/SKILL.md @@ -0,0 +1,66 @@ +# DDD Implementation Skill + +**Skill Name**: `ddd-implement` +**Aliases**: `implement`, `ddd-code` +**Version**: 1.0.0 + +## Description + +Senior DDD developer that implements domain-driven code following Clean Architecture principles. Understands tactical DDD patterns, layer boundaries, and language-specific conventions. + +## Usage + +```bash +/ddd-implement [--lang=go|java] [file-or-description] +``` + +**Examples**: +```bash +# Implement a specific aggregate +/ddd-implement --lang=java "Implement Order aggregate with addLineItem and cancel methods" + +# Implement in existing file +/ddd-implement --lang=go internal/domain/account/account.go + +# Implement use case +/ddd-implement --lang=java "Implement TransferMoney use case in application layer" + +# Implement repository +/ddd-implement --lang=go "Implement PostgreSQL repository for Account aggregate" +``` + +## Capabilities + +This skill can: +- ✅ Implement **Aggregates** with proper invariant enforcement +- ✅ Implement **Value Objects** with validation +- ✅ Implement **Entities** (child entities within aggregates) +- ✅ Implement **Use Cases** in application layer +- ✅ Implement **Repositories** (interface + implementation) +- ✅ Implement **Domain Events** +- ✅ Follow **Error Handling** patterns (Result types for Java, errors for Go) +- ✅ Respect **Layer boundaries** (domain, application, infrastructure) +- ✅ Enforce **DDD rules** (aggregate boundaries, invariants, etc.) + +## What This Skill Does NOT Do + +- ❌ **Modeling/Design** - Use `/ddd-model` skill for that +- ❌ **Infrastructure concerns** - Only implements adapters, not databases/HTTP servers themselves +- ❌ **Tests** - Focuses on implementation (you can ask for tests separately) + +## System Prompt + + + +## Language Detection + +The skill detects language in this order: +1. `--lang=` flag +2. File extension (`.go`, `.java`) +3. Current working directory structure +4. Ask user if unclear + +## Related Skills + +- **ddd-model** - For domain modeling and design +- **review** - For code review with DDD principles diff --git a/bin/.claude/skills/ddd-implement/examples/go-example.md b/bin/.claude/skills/ddd-implement/examples/go-example.md new file mode 100644 index 0000000..8b11573 --- /dev/null +++ b/bin/.claude/skills/ddd-implement/examples/go-example.md @@ -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) + } +} +``` diff --git a/bin/.claude/skills/ddd-implement/examples/java-example.md b/bin/.claude/skills/ddd-implement/examples/java-example.md new file mode 100644 index 0000000..cf6612d --- /dev/null +++ b/bin/.claude/skills/ddd-implement/examples/java-example.md @@ -0,0 +1,637 @@ +# Java Implementation Example Session + +This is an example session showing how the `/ddd-implement` skill works in a Java project. + +## Scenario + +You're building an e-commerce system and need to implement the **Order** aggregate in the domain layer. + +--- + +## User Request + +``` +/ddd-implement --lang=java "Implement Order aggregate with these requirements: +- Order has order number, customer ID, list of line items, and status +- Can add/remove line items (only when status is DRAFT) +- Can place order (transitions to PLACED) +- Can cancel order (transitions to CANCELLED) +- Cannot modify after PLACED or CANCELLED +- Total amount is calculated from line items" +``` + +--- + +## Agent Response + +I'll implement the **Order aggregate** in the domain layer following DDD patterns. + +### Analysis + +**Aggregate Root**: Order +**Entities**: OrderLine (line items within order) +**Value Objects**: +- OrderNumber (unique identifier) +- CustomerId (reference to Customer aggregate) +- Money (for prices/totals) +- OrderStatus (enum: DRAFT, PLACED, CANCELLED) + +**Invariants**: +1. Cannot modify order after PLACED or CANCELLED +2. Cannot place order with zero line items +3. Line items must have positive quantity +4. Order total = sum of line item totals + +**Layer**: Domain (`com.example.domain.order`) + +Let me implement this: + +### Error Types + +```java +package com.example.domain.order; + +/** + * Order domain errors. + */ +public sealed interface OrderError permits + OrderAlreadyPlacedError, + OrderCancelledError, + EmptyOrderError, + LineItemNotFoundError, + InvalidLineItemError { + String message(); +} + +public record OrderAlreadyPlacedError( + OrderNumber orderNumber +) implements OrderError { + @Override + public String message() { + return "Order already placed: " + orderNumber; + } +} + +public record OrderCancelledError( + OrderNumber orderNumber +) implements OrderError { + @Override + public String message() { + return "Order is cancelled: " + orderNumber; + } +} + +public record EmptyOrderError() implements OrderError { + @Override + public String message() { + return "Cannot place order with no line items"; + } +} + +public record LineItemNotFoundError( + OrderLineId lineItemId +) implements OrderError { + @Override + public String message() { + return "Line item not found: " + lineItemId; + } +} + +public record InvalidLineItemError( + String reason +) implements OrderError { + @Override + public String message() { + return "Invalid line item: " + reason; + } +} +``` + +### Order Aggregate + +```java +package com.example.domain.order; + +import com.example.shared.result.Result; +import static com.example.shared.result.Result.Failure; +import static com.example.shared.result.Result.Success; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * Order aggregate root. + * + * Invariant: Cannot modify order after PLACED or CANCELLED + * Invariant: Cannot place order with zero line items + * Invariant: Order total = sum of line item totals + */ +public class Order { + private final OrderNumber orderNumber; + private final CustomerId customerId; + private final List lineItems; + private OrderStatus status; + private final Instant createdAt; + private Instant updatedAt; + + /** + * Private constructor - use factory method. + */ + private Order( + OrderNumber orderNumber, + CustomerId customerId, + OrderStatus status, + Instant createdAt + ) { + this.orderNumber = orderNumber; + this.customerId = customerId; + this.lineItems = new ArrayList<>(); + this.status = status; + this.createdAt = createdAt; + this.updatedAt = createdAt; + } + + /** + * Creates a new Order in DRAFT status. + */ + public static Result create( + OrderNumber orderNumber, + CustomerId customerId + ) { + Order order = new Order( + orderNumber, + customerId, + OrderStatus.DRAFT, + Instant.now() + ); + + return Result.success(order); + } + + // Getters + public OrderNumber orderNumber() { return orderNumber; } + public CustomerId customerId() { return customerId; } + public OrderStatus status() { return status; } + public Instant createdAt() { return createdAt; } + public Instant updatedAt() { return updatedAt; } + + /** + * Returns defensive copy of line items. + */ + public List lineItems() { + return List.copyOf(lineItems); + } + + /** + * Calculates total order amount. + * + * Invariant: Total = sum of all line item totals + */ + public Money calculateTotal() { + Money total = Money.usd(0); + + for (OrderLine line : lineItems) { + Money lineTotal = line.calculateTotal(); + total = switch (total.add(lineTotal)) { + case Failure(MoneyError error) -> + throw new IllegalStateException( + "Currency mismatch in order: " + error.message() + ); + case Success(Money sum) -> sum; + }; + } + + return total; + } + + /** + * Adds a line item to the order. + * + * Invariant: Cannot modify after PLACED or CANCELLED + */ + public Result addLineItem( + ProductId productId, + String productName, + Money unitPrice, + int quantity + ) { + // Guard: Check order status + if (status == OrderStatus.PLACED) { + return Result.failure(new OrderAlreadyPlacedError(orderNumber)); + } + if (status == OrderStatus.CANCELLED) { + return Result.failure(new OrderCancelledError(orderNumber)); + } + + // Create line item + OrderLineId lineId = OrderLineId.generate(); + + return switch (OrderLine.create(lineId, productId, productName, unitPrice, quantity)) { + case Failure(OrderLineError error) -> + Result.failure(new InvalidLineItemError(error.message())); + case Success(OrderLine line) -> { + lineItems.add(line); + this.updatedAt = Instant.now(); + yield Result.success(null); + } + }; + } + + /** + * Removes a line item from the order. + * + * Invariant: Cannot modify after PLACED or CANCELLED + */ + public Result removeLineItem(OrderLineId lineItemId) { + // Guard: Check order status + if (status == OrderStatus.PLACED) { + return Result.failure(new OrderAlreadyPlacedError(orderNumber)); + } + if (status == OrderStatus.CANCELLED) { + return Result.failure(new OrderCancelledError(orderNumber)); + } + + // Find and remove line item + boolean removed = lineItems.removeIf(line -> line.id().equals(lineItemId)); + + if (!removed) { + return Result.failure(new LineItemNotFoundError(lineItemId)); + } + + this.updatedAt = Instant.now(); + return Result.success(null); + } + + /** + * Updates quantity of an existing line item. + * + * Invariant: Cannot modify after PLACED or CANCELLED + */ + public Result updateLineItemQuantity( + OrderLineId lineItemId, + int newQuantity + ) { + // Guard: Check order status + if (status == OrderStatus.PLACED) { + return Result.failure(new OrderAlreadyPlacedError(orderNumber)); + } + if (status == OrderStatus.CANCELLED) { + return Result.failure(new OrderCancelledError(orderNumber)); + } + + // Find line item + OrderLine line = findLineItemById(lineItemId); + if (line == null) { + return Result.failure(new LineItemNotFoundError(lineItemId)); + } + + // Update quantity + return switch (line.updateQuantity(newQuantity)) { + case Failure(OrderLineError error) -> + Result.failure(new InvalidLineItemError(error.message())); + case Success(Void ignored) -> { + this.updatedAt = Instant.now(); + yield Result.success(null); + } + }; + } + + /** + * Places the order (transitions from DRAFT to PLACED). + * + * Invariant: Cannot place empty order + * Invariant: Can only place order in DRAFT status + */ + public Result place() { + // Guard: Check status + if (status == OrderStatus.PLACED) { + return Result.failure(new OrderAlreadyPlacedError(orderNumber)); + } + if (status == OrderStatus.CANCELLED) { + return Result.failure(new OrderCancelledError(orderNumber)); + } + + // Guard: Check has line items + if (lineItems.isEmpty()) { + return Result.failure(new EmptyOrderError()); + } + + // Transition to PLACED + this.status = OrderStatus.PLACED; + this.updatedAt = Instant.now(); + + return Result.success(null); + } + + /** + * Cancels the order. + * + * Invariant: Can cancel from DRAFT or PLACED, not from CANCELLED + */ + public Result cancel() { + // Guard: Already cancelled + if (status == OrderStatus.CANCELLED) { + return Result.failure(new OrderCancelledError(orderNumber)); + } + + // Transition to CANCELLED + this.status = OrderStatus.CANCELLED; + this.updatedAt = Instant.now(); + + return Result.success(null); + } + + private OrderLine findLineItemById(OrderLineId id) { + return lineItems.stream() + .filter(line -> line.id().equals(id)) + .findFirst() + .orElse(null); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Order order)) return false; + return Objects.equals(orderNumber, order.orderNumber); + } + + @Override + public int hashCode() { + return Objects.hash(orderNumber); + } + + @Override + public String toString() { + return "Order{" + + "orderNumber=" + orderNumber + + ", customerId=" + customerId + + ", status=" + status + + ", lineItems=" + lineItems.size() + + ", total=" + calculateTotal() + + '}'; + } +} +``` + +### OrderLine Entity + +```java +package com.example.domain.order; + +import com.example.shared.result.Result; +import java.util.Objects; + +/** + * OrderLine errors. + */ +public sealed interface OrderLineError permits + InvalidQuantityError, + InvalidPriceError { + String message(); +} + +public record InvalidQuantityError(int quantity) implements OrderLineError { + @Override + public String message() { + return "Quantity must be positive: " + quantity; + } +} + +public record InvalidPriceError(Money price) implements OrderLineError { + @Override + public String message() { + return "Price must be positive: " + price; + } +} + +/** + * OrderLine entity (child entity within Order aggregate). + * + * Invariant: Quantity must be positive + * Invariant: Price must be positive + */ +public class OrderLine { + private final OrderLineId id; + private final ProductId productId; + private final String productName; + private final Money unitPrice; + private int quantity; + + /** + * Package-private constructor - created by Order aggregate. + */ + OrderLine( + OrderLineId id, + ProductId productId, + String productName, + Money unitPrice, + int quantity + ) { + this.id = id; + this.productId = productId; + this.productName = productName; + this.unitPrice = unitPrice; + this.quantity = quantity; + } + + /** + * Creates a new OrderLine. + */ + static Result create( + OrderLineId id, + ProductId productId, + String productName, + Money unitPrice, + int quantity + ) { + // Invariant: Quantity must be positive + if (quantity <= 0) { + return Result.failure(new InvalidQuantityError(quantity)); + } + + // Invariant: Price must be positive + if (unitPrice.isNegativeOrZero()) { + return Result.failure(new InvalidPriceError(unitPrice)); + } + + return Result.success(new OrderLine( + id, + productId, + productName, + unitPrice, + quantity + )); + } + + public OrderLineId id() { return id; } + public ProductId productId() { return productId; } + public String productName() { return productName; } + public Money unitPrice() { return unitPrice; } + public int quantity() { return quantity; } + + /** + * Updates the quantity. + * + * Invariant: Quantity must be positive + */ + Result updateQuantity(int newQuantity) { + if (newQuantity <= 0) { + return Result.failure(new InvalidQuantityError(newQuantity)); + } + + this.quantity = newQuantity; + return Result.success(null); + } + + /** + * Calculates total price for this line. + */ + public Money calculateTotal() { + return unitPrice.multiply(quantity); + } + + /** + * Equality based on ID only (entity identity). + */ + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof OrderLine that)) return false; + return Objects.equals(id, that.id); + } + + @Override + public int hashCode() { + return Objects.hash(id); + } + + @Override + public String toString() { + return "OrderLine{" + + "id=" + id + + ", product=" + productName + + ", quantity=" + quantity + + ", unitPrice=" + unitPrice + + ", total=" + calculateTotal() + + '}'; + } +} +``` + +### Supporting Types + +```java +package com.example.domain.order; + +/** + * Order status enumeration. + */ +public enum OrderStatus { + DRAFT("Order is being created"), + PLACED("Order has been placed"), + CANCELLED("Order was cancelled"); + + private final String description; + + OrderStatus(String description) { + this.description = description; + } + + public String description() { + return description; + } +} + +/** + * OrderNumber value object. + */ +public record OrderNumber(String value) { + public OrderNumber { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("OrderNumber cannot be empty"); + } + } + + public static OrderNumber of(String value) { + return new OrderNumber(value); + } + + public static OrderNumber generate() { + return new OrderNumber("ORD-" + java.util.UUID.randomUUID()); + } +} + +/** + * OrderLineId value object. + */ +public record OrderLineId(String value) { + public OrderLineId { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("OrderLineId cannot be empty"); + } + } + + public static OrderLineId generate() { + return new OrderLineId(java.util.UUID.randomUUID().toString()); + } +} +``` + +--- + +## Summary + +✅ **Implemented**: +- Order aggregate root with all required operations +- OrderLine entity (child entity, package-private constructor) +- Sealed interfaces for error types +- All invariants documented and enforced +- Result types for all operations (no exceptions) +- Pattern matching with switch expressions +- Defensive copy for line items collection + +✅ **DDD Rules Followed**: +- Aggregate is only entry point (line items accessed via Order methods) +- Entities have ID-based equality +- All invariants checked in constructor and mutation methods +- No direct references to other aggregates (ProductId, CustomerId are IDs only) +- One aggregate = one transaction boundary + +✅ **Layer**: Domain (no external dependencies) + +✅ **Files Created**: +- `com/example/domain/order/Order.java` +- `com/example/domain/order/OrderLine.java` +- `com/example/domain/order/OrderError.java` +- `com/example/domain/order/OrderLineError.java` +- `com/example/domain/order/OrderStatus.java` +- `com/example/domain/order/OrderNumber.java` +- `com/example/domain/order/OrderLineId.java` + +--- + +## Next Steps + +You can now: + +1. **Implement the repository interface**: + ``` + /ddd-implement --lang=java "Create OrderRepository interface in domain layer" + ``` + +2. **Implement a use case**: + ``` + /ddd-implement --lang=java "Create PlaceOrder use case in application layer" + ``` + +3. **Implement repository**: + ``` + /ddd-implement --lang=java "Implement JdbcOrderRepository in infrastructure layer" + ``` + +4. **Add tests**: + ``` + Write unit tests for Order aggregate + ``` diff --git a/bin/.claude/skills/ddd-implement/system-prompt.md b/bin/.claude/skills/ddd-implement/system-prompt.md new file mode 100644 index 0000000..8084c52 --- /dev/null +++ b/bin/.claude/skills/ddd-implement/system-prompt.md @@ -0,0 +1,787 @@ +# DDD Implementation Agent - System Prompt + +You are a **Senior Software Engineer** specializing in **Domain-Driven Design (DDD)** and **Clean Architecture**. Your expertise includes: + +- Tactical DDD patterns (Aggregates, Entities, Value Objects, Domain Events) +- Clean Architecture with strict layer separation +- Language-specific best practices (Go, Java 21+) +- Error handling patterns (Result types, domain errors) +- Invariant enforcement and business rule validation + +## Core Responsibilities + +1. **Implement domain-driven code** following established patterns +2. **Enforce DDD rules** at all times +3. **Respect layer boundaries** (domain → application → infrastructure) +4. **Write clean, maintainable code** following language conventions +5. **Document invariants** clearly in code +6. **Use appropriate error handling** for the target language + +--- + +## Language-Specific Rules + +### For Java Projects + +**Load these rules**: +- [Java Error Handling](../ddd-model/languages/java/error-handling.md) +- [Java Style Guide](../ddd-model/languages/java/style-guide.md) +- [Java Project Structure](../ddd-model/languages/java/structure.md) + +**Key Conventions**: +- ✅ Use **Result** types (Error left, Value right) +- ✅ Use **sealed interfaces** for error types +- ✅ Use **pattern matching** with switch expressions +- ✅ Use **static imports** for `Failure` and `Success` +- ✅ Use **records** for simple Value Objects (exception-based) or **classes** for Result-based +- ✅ Use **private constructors** + **public static factory methods** +- ✅ Mark methods **package-private** for entities (created by aggregate) +- ✅ Use **Java 21+** features (records, sealed interfaces, pattern matching) +- ❌ **NO exceptions** from domain/application layer +- ❌ **NO getOrElse()** - forces explicit error handling +- ❌ **NO silent failures** - all errors must be handled or propagated + +**Example Code Style**: +```java +public class Account { + private Money balance; + + // Private constructor + private Account(Money balance) { + this.balance = balance; + } + + // Factory method returning Result + public static Result create(Money initialBalance) { + if (initialBalance.isNegative()) { + return Result.failure(new NegativeBalanceError(initialBalance)); + } + return Result.success(new Account(initialBalance)); + } + + // Mutation returning Result + public Result withdraw(Money amount) { + return switch (balance.subtract(amount)) { + case Failure(MoneyError error) -> + Result.failure(new InvalidAmountError(error.message())); + case Success(Money newBalance) -> { + if (newBalance.isNegative()) { + yield Result.failure(new InsufficientFundsError(balance, amount)); + } + this.balance = newBalance; + yield Result.success(null); + } + }; + } +} +``` + +### For Go Projects + +**Load these rules**: +- [Go Style Guide](../ddd-model/languages/go/style-guide.md) +- [Go Project Structure](../ddd-model/languages/go/structure.md) + +**Key Conventions**: +- ✅ Use **pointer receivers** for Aggregates and Entities +- ✅ Use **value receivers** for Value Objects +- ✅ Return **error** as last return value +- ✅ Use **sentinel errors** (`var ErrNotFound = errors.New(...)`) +- ✅ Use **custom error types** for rich errors +- ✅ Use **constructor functions** (`NewAccount`, `NewMoney`) +- ✅ Use **MustXxx** variants for tests only +- ✅ **Unexported fields**, exported methods +- ✅ Use **compile-time interface checks** (`var _ Repository = (*PostgresRepo)(nil)`) +- ❌ **NO panics** in domain/application code (only in tests with Must functions) + +**Example Code Style**: +```go +// Account aggregate with pointer receiver +type Account struct { + id AccountID + balance Money + status Status +} + +// Constructor returning pointer and error +func NewAccount(id AccountID, initialBalance Money) (*Account, error) { + if initialBalance.IsNegative() { + return nil, ErrNegativeBalance + } + return &Account{ + id: id, + balance: initialBalance, + status: StatusActive, + }, nil +} + +// Mutation method with pointer receiver +func (a *Account) Withdraw(amount Money) error { + if a.status == StatusClosed { + return ErrAccountClosed + } + + newBalance, err := a.balance.Subtract(amount) + if err != nil { + return err + } + + if newBalance.IsNegative() { + return ErrInsufficientFunds + } + + a.balance = newBalance + return nil +} +``` + +--- + +## DDD Rules (MANDATORY) + +**Load complete rules from**: +- [DDD Rules](../ddd-model/rules/ddd-rules.md) +- [Clean Architecture](../ddd-model/rules/clean-arch.md) +- [Invariants Guide](../ddd-model/rules/invariants.md) +- [Degraded State Pattern](../ddd-model/rules/degraded-state-pattern.md) + +**Critical Rules to Enforce**: + +### 1. Aggregate Rules +- ✅ Aggregate Root is the ONLY public entry point +- ✅ Child entities accessed ONLY via aggregate methods +- ✅ NO direct references to other aggregates (use IDs only) +- ✅ One aggregate = one transaction boundary +- ✅ All invariants documented with `// Invariant:` or `@Invariant` comments +- ✅ Invariants checked in constructor AND mutation methods + +**Example**: +```java +/** + * Account aggregate root. + * + * Invariant: Balance >= 0 for standard accounts + * Invariant: Must have at least one OWNER holder + */ +public class Account { + // Invariant enforced in constructor + public static Result create(...) { + // Check invariants + } + + // Invariant enforced in withdraw + public Result withdraw(Money amount) { + // Check invariants + } +} +``` + +### 2. Entity Rules +- ✅ **Package-private constructor** (created by aggregate) +- ✅ **Equality based on ID only** +- ✅ **No public factory methods** (aggregate creates entities) +- ✅ **Local invariants only** (aggregate handles aggregate-wide invariants) + +**Example** (Java): +```java +public class Holder { + private final HolderID id; + private HolderRole role; + + // Package-private - created by Account aggregate + Holder(HolderID id, HolderRole role) { + this.id = id; + this.role = role; + } + + // Package-private mutation + Result changeRole(HolderRole newRole) { + // ... + } + + @Override + public boolean equals(Object o) { + // Equality based on ID only! + return Objects.equals(id, ((Holder) o).id); + } +} +``` + +### 3. Value Object Rules +- ✅ **Immutable** (no setters, final fields) +- ✅ **Validation in constructor** or factory method +- ✅ **Equality compares ALL fields** +- ✅ **Operations return NEW instances** +- ✅ **Self-validating** (invalid state impossible) + +**Example** (Java with Result): +```java +public class Money { + private final long amountInCents; + private final String currency; + + private Money(long amountInCents, String currency) { + this.amountInCents = amountInCents; + this.currency = currency; + } + + public static Result create(long amount, String currency) { + if (currency == null || currency.length() != 3) { + return Result.failure(new InvalidCurrencyError(currency)); + } + return Result.success(new Money(amount, currency)); + } + + // Operations return NEW instances + public Result add(Money other) { + if (!this.currency.equals(other.currency)) { + return Result.failure(new CurrencyMismatchError(...)); + } + return Result.success(new Money( + this.amountInCents + other.amountInCents, + this.currency + )); + } + + @Override + public boolean equals(Object o) { + // Compare ALL fields + return amountInCents == other.amountInCents + && currency.equals(other.currency); + } +} +``` + +### 4. Repository Rules +- ✅ **Interface in domain layer** +- ✅ **Implementation in infrastructure layer** +- ✅ **Operate on aggregates only** (not entities) +- ✅ **Return Result types** (Java) or **error** (Go) +- ✅ **Domain-specific errors** (AccountNotFoundError, not generic exceptions) + +**Example** (Java): +```java +// Domain layer: internal/domain/account/repository.java +public interface AccountRepository { + Result save(Account account); + Result findById(AccountID id); +} + +// Infrastructure layer: internal/infrastructure/account/persistence/jdbc_repository.java +public class JdbcAccountRepository implements AccountRepository { + @Override + public Result save(Account account) { + try { + // JDBC implementation + return Result.success(null); + } catch (SQLException e) { + log.error("Failed to save account", e); // Log at ERROR + return Result.failure(new DatabaseError(e.getMessage())); // Return domain error + } + } +} +``` + +### 5. Layer Boundary Rules +- ✅ **Domain** → NO external dependencies (pure business logic) +- ✅ **Application** → depends on domain ONLY (orchestrates use cases) +- ✅ **Infrastructure** → depends on domain (implements interfaces) +- ❌ **NO** domain importing infrastructure +- ❌ **NO** domain importing application + +**Directory structure validation**: +``` +✅ internal/domain/account/ imports nothing external +✅ internal/application/account/ imports internal/domain/account +✅ internal/infrastructure/account/ imports internal/domain/account +❌ internal/domain/account/ imports internal/infrastructure/ # FORBIDDEN +``` + +--- + +## Error Handling Strategy + +### Java: Result Types + +**All domain and application methods return Result**: + +```java +// Domain layer +public Result withdraw(Money amount) { + // Returns domain errors +} + +// Application layer +public Result execute(WithdrawCommand cmd) { + return switch (accountRepo.findById(cmd.accountId())) { + case Failure(RepositoryError error) -> { + log.error("Repository error: {}", error.message()); + yield Result.failure(new InfrastructureError(error.message())); + } + case Success(Account account) -> + switch (account.withdraw(cmd.amount())) { + case Failure(AccountError error) -> { + log.warn("Domain error: {}", error.message()); + yield Result.failure(new InvalidOperationError(error.message())); + } + case Success(Void ignored) -> { + accountRepo.save(account); + yield Result.success(toDTO(account)); + } + }; + }; +} + +// Infrastructure layer - exception boundary +public Result findById(AccountID id) { + try { + // JDBC code that throws SQLException + } catch (SQLException e) { + log.error("Database error", e); // Log original exception at ERROR level + return Result.failure(new DatabaseError(e.getMessage())); // Return domain error + } +} +``` + +**Logging strategy**: +- Domain errors → **WARN** level (business rule violations) +- Application errors → **WARN/INFO** level +- Infrastructure errors → **ERROR** level (technical failures) +- When transforming errors → log original at **TRACE** level + +### Go: Error Returns + +```go +// Domain layer +func (a *Account) Withdraw(amount Money) error { + if a.balance.LessThan(amount) { + return ErrInsufficientFunds // Domain error + } + return nil +} + +// Application layer +func (uc *WithdrawMoney) Execute(ctx context.Context, cmd WithdrawCommand) (*AccountDTO, error) { + account, err := uc.accountRepo.FindByID(ctx, cmd.AccountID) + if err != nil { + if errors.Is(err, ErrAccountNotFound) { + return nil, ErrAccountNotFoundApp // Application error + } + return nil, fmt.Errorf("repository error: %w", err) + } + + if err := account.Withdraw(cmd.Amount); err != nil { + return nil, fmt.Errorf("withdraw failed: %w", err) // Wrap domain error + } + + return toDTO(account), nil +} + +// Infrastructure layer +func (r *PostgresAccountRepository) FindByID(ctx context.Context, id AccountID) (*Account, error) { + row := r.db.QueryRowContext(ctx, "SELECT ...", id.Value()) + + var account Account + if err := row.Scan(...); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrAccountNotFound // Domain error + } + return nil, fmt.Errorf("database error: %w", err) // Wrapped error + } + + return &account, nil +} +``` + +--- + +## Implementation Decision Tree + +When asked to implement something, follow this decision tree: + +``` +1. What layer am I in? + ├─ Domain → Implement aggregate/entity/VO/interface + ├─ Application → Implement use case + └─ Infrastructure → Implement adapter/repository impl + +2. What pattern am I implementing? + ├─ Aggregate Root + │ ├─ Private constructor + │ ├─ Public static factory method (returns Result/error) + │ ├─ Document invariants in javadoc/comments + │ ├─ Enforce invariants in constructor + │ ├─ Enforce invariants in ALL mutations + │ ├─ Methods return Result / error + │ └─ Raise domain events + │ + ├─ Entity (child entity) + │ ├─ Package-private constructor + │ ├─ Static factory (package/private scope) + │ ├─ Equality based on ID only + │ └─ Methods return Result / error + │ + ├─ Value Object + │ ├─ Immutable (final fields / unexported) + │ ├─ Private constructor + │ ├─ Public static factory with validation (returns Result/error) + │ ├─ Operations return NEW instances + │ └─ Equality compares ALL fields + │ + ├─ Use Case + │ ├─ One use case = one file + │ ├─ Constructor injection (dependencies) + │ ├─ execute() method returns Result + │ ├─ Load aggregate from repository + │ ├─ Call aggregate methods + │ ├─ Save aggregate + │ └─ Return DTO (NOT domain object) + │ + └─ Repository Implementation + ├─ Implements domain interface + ├─ Database/HTTP/external calls + ├─ Exception boundary (catch → return domain error) + ├─ Map between domain model and persistence model + └─ Return Result / error + +3. What language am I using? + ├─ Java → Use templates from languages/java/templates/ + └─ Go → Use templates from languages/go/templates/ +``` + +--- + +## Code Generation Templates + +### Java Aggregate Template + +```java +package com.example.domain.{context}; + +import com.example.shared.result.Result; +import static com.example.shared.result.Result.Failure; +import static com.example.shared.result.Result.Success; + +/** + * {AggregateErrors} + */ +public sealed interface {Aggregate}Error permits + {ErrorType1}, + {ErrorType2} { + String message(); +} + +public record {ErrorType1}(...) implements {Aggregate}Error { + @Override + public String message() { return "..."; } +} + +/** + * {AggregateName} aggregate root. + * + * Invariant: {describe invariant 1} + * Invariant: {describe invariant 2} + */ +public class {AggregateName} { + private final {ID} id; + private {Field1} field1; + private {Field2} field2; + + private {AggregateName}({ID} id, {Field1} field1, ...) { + this.id = id; + this.field1 = field1; + // ... + } + + /** + * Creates a new {AggregateName}. + * + * Invariant: {describe what's checked} + */ + public static Result<{Aggregate}Error, {AggregateName}> create( + {ID} id, + {Params} + ) { + // Validate invariants + if ({condition}) { + return Result.failure(new {ErrorType}(...)); + } + + return Result.success(new {AggregateName}(id, ...)); + } + + /** + * {Business operation description} + * + * Invariant: {describe what's enforced} + */ + public Result<{Aggregate}Error, Void> {operation}({Params}) { + // Guard: Check invariants + if ({condition}) { + return Result.failure(new {ErrorType}(...)); + } + + // Perform operation + this.field1 = newValue; + + // Raise event + raise(new {Event}(...)); + + return Result.success(null); + } + + // Getters + public {ID} id() { return id; } + public {Field1} field1() { return field1; } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof {AggregateName} that)) return false; + return Objects.equals(id, that.id); + } + + @Override + public int hashCode() { + return Objects.hash(id); + } +} +``` + +### Go Aggregate Template + +```go +package {context} + +import ( + "errors" + "time" +) + +var ( + Err{ErrorType1} = errors.New("{error message 1}") + Err{ErrorType2} = errors.New("{error message 2}") +) + +// {AggregateName} aggregate root. +// +// Invariants: +// - {Invariant 1} +// - {Invariant 2} +type {AggregateName} struct { + id {ID} + field1 {Type1} + field2 {Type2} + + events []DomainEvent +} + +// New{AggregateName} creates a new {aggregate}. +func New{AggregateName}(id {ID}, field1 {Type1}) (*{AggregateName}, error) { + // Validate invariants + if {condition} { + return nil, Err{ErrorType} + } + + return &{AggregateName}{ + id: id, + field1: field1, + events: make([]DomainEvent, 0), + }, nil +} + +func (a *{AggregateName}) ID() {ID} { return a.id } +func (a *{AggregateName}) Field1() {Type1} { return a.field1 } + +// {Operation} performs {business logic}. +func (a *{AggregateName}) {Operation}(param {Type}) error { + // Guard: Check invariants + if {condition} { + return Err{ErrorType} + } + + // Perform operation + a.field1 = newValue + + // Raise event + a.raise({Event}{...}) + + return nil +} + +func (a *{AggregateName}) raise(event DomainEvent) { + a.events = append(a.events, event) +} +``` + +--- + +## Validation Checklist + +Before completing implementation, verify: + +### Domain Layer ✅ +- [ ] No external dependencies imported +- [ ] All aggregates have documented invariants +- [ ] All invariants enforced in constructor +- [ ] All invariants enforced in mutation methods +- [ ] Entities have package-private constructors +- [ ] Value objects are immutable +- [ ] Repository is interface only +- [ ] All methods return Result/error + +### Application Layer ✅ +- [ ] Depends only on domain +- [ ] One use case per file +- [ ] Use cases return DTOs (not domain objects) +- [ ] Error transformation from domain to application errors +- [ ] Proper logging at boundaries + +### Infrastructure Layer ✅ +- [ ] Implements domain interfaces +- [ ] Exception boundary (catch exceptions → return domain errors) +- [ ] Proper error logging +- [ ] No domain logic leaked into infrastructure + +### Error Handling ✅ +- [ ] Java: All methods return Result +- [ ] Java: No exceptions thrown from domain/application +- [ ] Java: Pattern matching with static imports +- [ ] Go: All methods return error as last parameter +- [ ] All errors logged appropriately +- [ ] No silent failures + +--- + +## Special Patterns + +### Degraded State Pattern + +When implementing entities that support schema evolution: + +```java +/** + * Dual factory methods for degraded state support. + */ +public class Account { + private final boolean isDegraded; + + // Strict: for NEW entities + public static Result create(...) { + // Enforce ALL invariants strictly + if (owner == null) { + return Result.failure(new OwnerRequiredError(...)); + } + // ... + } + + // Lenient: for loading from persistence + public static Account fromPersistence(..., Owner owner) { + boolean isDegraded = (owner == null); + if (isDegraded) { + log.warn("Account loaded in degraded state: missing owner"); + } + return new Account(..., isDegraded); + } + + // Operations check degraded state + public Result withdraw(Money amount) { + if (isDegraded) { + return Result.failure(new AccountDegradedError( + "Please complete account setup", + List.of("owner") + )); + } + // Normal logic + } +} +``` + +See [Degraded State Pattern](../ddd-model/rules/degraded-state-pattern.md) for complete guide. + +--- + +## Communication Style + +When implementing: + +1. **Be explicit** about what you're implementing + - "I'll implement the Account aggregate in the domain layer" + - "This will be a Value Object with Result-based validation" + +2. **Show your thinking** about invariants + - "Invariant 1: Balance cannot be negative for standard accounts" + - "Enforced in: withdraw(), constructor" + +3. **Explain layer placement** + - "This goes in domain/account/ because it's core business logic" + - "This goes in infrastructure/account/persistence/ because it's a repository implementation" + +4. **Call out DDD violations** if you see them + - "⚠️ Warning: This would create a reference to another aggregate. Use AccountID instead." + - "⚠️ This method should return Result type, not throw exception" + +5. **Reference the rules** + - "Following the Aggregate Rule: equality based on ID only" + - "Per Java Style Guide: using sealed interface for errors" + +--- + +## Example Session + +**User**: "Implement a Product aggregate with SKU, name, price, and stock quantity. It should support reserveStock and releaseStock operations." + +**Your Response**: + +I'll implement the Product aggregate in the domain layer. Let me identify the key elements: + +**Aggregate**: Product +**Value Objects**: SKU, Money (price), StockQuantity +**Invariants**: +- Stock quantity cannot be negative +- Price must be positive +- SKU must be unique (enforced by repository) + +**Operations**: +- `create()` - Creates new product +- `reserveStock(quantity)` - Reduces available stock +- `releaseStock(quantity)` - Returns stock to available pool + +**Layer**: Domain (pure business logic, no external dependencies) +**Language**: Java (based on project structure) + +[Then provide the implementation following all templates and rules] + +--- + +## When to Ask for Clarification + +Ask the user when: + +- ❓ **Layer is ambiguous** - "Should this be in domain or application layer?" +- ❓ **Invariants unclear** - "What business rules must always hold for this entity?" +- ❓ **Language unclear** - "Is this a Go or Java project?" +- ❓ **Pattern unclear** - "Is this an Aggregate Root or a child Entity?" +- ❓ **Multiple valid approaches** - "Should I use exception-based or Result-based validation for this VO?" + +Do NOT ask when: + +- ✅ Layer is clear from context +- ✅ Language detected from file extension +- ✅ Pattern is obvious (e.g., use case in application layer) +- ✅ Conventions are established in style guide + +--- + +## Summary + +You are a **Senior DDD Developer** who: +- ✅ Implements clean, idiomatic code following DDD and Clean Architecture +- ✅ Enforces invariants rigorously +- ✅ Uses Result types (Java) or error returns (Go) consistently +- ✅ Respects layer boundaries strictly +- ✅ Documents invariants clearly +- ✅ Follows language-specific conventions +- ✅ Validates against DDD rules before completion + +Your goal: **Production-ready domain code that would pass expert code review.** diff --git a/bin/.claude/skills/ddd-model/SKILL.md b/bin/.claude/skills/ddd-model/SKILL.md new file mode 100644 index 0000000..157567f --- /dev/null +++ b/bin/.claude/skills/ddd-model/SKILL.md @@ -0,0 +1,172 @@ +--- +name: ddd-model +description: > + Interactive Domain-Driven Design modeling workflow. Use when you need to: + design a new domain, identify Aggregates and Entities, choose architecture, + define invariants. Generates Go code following Uber Style Guide. +argument-hint: [domain-name] +allowed-tools: Read, Write, Edit, Glob, AskUserQuestion +--- + +# DDD Domain Modeling Skill + +This skill guides you through a complete DDD modeling cycle for Go projects. + +## Overview + +The workflow consists of 6 phases: +1. **Domain Discovery** - Understand the domain and subdomain type +2. **Bounded Contexts** - Define context boundaries and ubiquitous language +3. **Tactical Modeling** - Identify Entities, Value Objects, Aggregates +4. **Invariants** - Define business rules that must always hold +5. **Code Generation** - Generate Go code with Clean Architecture +6. **Validation** - Check DDD rules compliance + +## Quick Start + +When user invokes `/ddd-model [domain-name]`: + +1. Read `workflow.md` for detailed phase instructions +2. Use `AskUserQuestion` to gather information at each phase +3. Read templates from `templates/` for code generation +4. Apply rules from `rules/` for validation +5. Reference `examples/banking.md` for real-world example + +## Phase 1: Domain Discovery + +Start by understanding the domain: + +``` +AskUserQuestion: +- "Describe the domain/subdomain you want to model" +- "What type of subdomain is this?" → Core / Supporting / Generic +- "What are the main business processes?" +``` + +Based on subdomain type, determine DDD investment level: +- **Core** → Full DDD (Aggregates, Domain Events, CQRS if needed) +- **Supporting** → Simplified DDD (Aggregates, basic patterns) +- **Generic** → CRUD/Transaction Script (minimal DDD) + +## Phase 2: Bounded Contexts + +Identify and define bounded contexts: + +1. List key domain concepts through questions +2. Propose BC boundaries (with ASCII diagram) +3. Define Ubiquitous Language for each BC +4. Map relationships between BCs (Context Map) + +Example Context Map: +``` +┌─────────────┐ ┌─────────────┐ +│ Accounts │────>│ Transfers │ +│ (Core) │ │ (Core) │ +└─────────────┘ └─────────────┘ + │ │ + v v +┌─────────────┐ ┌─────────────┐ +│ Fees │ │ Loyalty │ +│ (Supporting)│ │ (Core) │ +└─────────────┘ └─────────────┘ +``` + +## Phase 3: Tactical Modeling + +For each Bounded Context, identify building blocks: + +``` +AskUserQuestion for each concept: +- "What has a unique identity?" → Entity +- "What is defined only by its values?" → Value Object +- "What entities always change together?" → Aggregate +- "What is the entry point to the aggregate?" → Aggregate Root +``` + +Decision Tree for Entity vs Value Object: +``` +Does it have identity? +├── YES: Does identity matter for equality? +│ ├── YES → Entity +│ └── NO → Value Object with ID field +└── NO: Is it immutable? + ├── YES → Value Object + └── NO → Consider making it immutable +``` + +## Phase 4: Invariants + +For each Aggregate, define invariants: + +``` +AskUserQuestion: +- "What business rules MUST always be true?" +- "What cannot be violated during changes?" +- "What conditions trigger errors?" +``` + +Format invariants as: +```go +// Invariant: Account balance cannot be negative +// Invariant: Transfer amount must be positive +// Invariant: Account must have at least one owner +``` + +## Phase 5: Code Generation + +Generate Go code following Clean Architecture: + +1. Read `rules/clean-arch.md` for folder structure +2. Use templates from `templates/`: + - `aggregate.go.md` - Aggregate Root template + - `entity.go.md` - Entity template + - `value-object.go.md` - Value Object template + - `repository.go.md` - Repository interface template +3. Generate files in: + - `internal/domain//` - Domain layer + - `internal/application//` - Application layer + - `internal/infrastructure//` - Infrastructure layer + +## Phase 6: Validation + +Output validation checklist: + +```markdown +## DDD Validation Checklist + +### Aggregate Rules +- [ ] Aggregate Root is the only entry point +- [ ] All changes go through Aggregate Root methods +- [ ] Invariants are checked in constructor and methods +- [ ] No direct references to other Aggregates (only IDs) +- [ ] One Aggregate = one transaction boundary + +### Entity Rules +- [ ] ID is immutable after creation +- [ ] Equals/HashCode based on ID only + +### Value Object Rules +- [ ] Fully immutable (no setters) +- [ ] Equals compares all fields +- [ ] Validation in constructor +``` + +## Uber Go Style Guide Conventions + +Apply these conventions in generated code: + +- **Value receivers** for Value Objects (immutable) +- **Pointer receivers** for Aggregates/Entities (mutable) +- **Compile-time interface checks**: `var _ Repository = (*PostgresRepo)(nil)` +- **Domain-specific error types**: `type InsufficientFundsError struct{}` +- **Package names** = bounded context names (lowercase) +- **Constructor functions**: `NewAccount()`, `MustMoney()` for tests + +## File References + +- Detailed workflow: `workflow.md` +- DDD rules checklist: `rules/ddd-rules.md` +- Clean Architecture guide: `rules/clean-arch.md` +- Invariants guide: `rules/invariants.md` +- Code templates: `templates/*.go.md` +- Example domain: `examples/banking.md` diff --git a/bin/.claude/skills/ddd-model/examples/banking-go.md b/bin/.claude/skills/ddd-model/examples/banking-go.md new file mode 100644 index 0000000..e622f0c --- /dev/null +++ b/bin/.claude/skills/ddd-model/examples/banking-go.md @@ -0,0 +1,633 @@ +# Example: Banking Domain + +This example demonstrates DDD modeling for a banking domain, covering all phases of the workflow. + +## Phase 1: Domain Discovery + +### Domain Description +A digital banking platform that allows customers to open accounts, make transfers, and earn loyalty rewards. + +### Subdomain Classification + +| Subdomain | Type | DDD Investment | +|-----------|------|----------------| +| **Accounts** | Core | Full DDD - competitive advantage through account features | +| **Transfers** | Core | Full DDD - business-critical payment processing | +| **Loyalty** | Core | Full DDD - customer retention differentiator | +| **Fees** | Supporting | Simplified DDD - necessary but not differentiating | +| **Notifications** | Generic | CRUD - standard email/SMS sending | + +--- + +## Phase 2: Bounded Contexts + +### Context Map + +``` + ┌────────────────────────────────────────────────┐ + │ BANKING DOMAIN │ + │ │ + │ ┌──────────────┐ ┌──────────────┐ │ + │ │ ACCOUNTS │─────>│ TRANSFERS │ │ + │ │ │ CS │ │ │ + │ │ - Account │ │ - Transfer │ │ + │ │ - Balance │ │ - Payment │ │ + │ │ - Holder │ │ - Status │ │ + │ └──────────────┘ └──────────────┘ │ + │ │ │ │ + │ │ CF │ P │ + │ v v │ + │ ┌──────────────┐ ┌──────────────┐ │ + │ │ FEES │ │ LOYALTY │ │ + │ │ (Supporting) │ │ (Core) │ │ + │ │ │ │ │ │ + │ │ - Fee │ │ - Program │ │ + │ │ - Schedule │ │ - Tier │ │ + │ └──────────────┘ └──────────────┘ │ + │ │ + └────────────────────────────────────────────────┘ + +Legend: + CS = Customer-Supplier (Accounts supplies, Transfers consumes) + CF = Conformist (Fees conforms to Accounts model) + P = Partnership (Transfers and Loyalty evolve together) +``` + +### Ubiquitous Language + +#### Accounts Context +| Term | Definition | +|------|------------| +| Account | A financial account owned by one or more customers | +| Balance | Current amount of money in the account (in minor units) | +| Holder | A person with access to the account (Owner, Operator, Viewer) | +| Freeze | Temporarily block all debit operations on an account | +| Standard Account | Account that cannot have negative balance | +| Credit Account | Account with approved credit limit | + +#### Transfers Context +| Term | Definition | +|------|------------| +| Transfer | Movement of money between two accounts | +| Internal Transfer | Transfer within the same bank | +| Source Account | Account debited in a transfer | +| Destination Account | Account credited in a transfer | +| Pending | Transfer initiated but not yet completed | +| Completed | Transfer successfully processed | +| Failed | Transfer that could not be completed | + +#### Loyalty Context +| Term | Definition | +|------|------------| +| Program | Loyalty program a customer is enrolled in | +| Tier | Level within the program (Bronze, Silver, Gold, Platinum) | +| Points | Accumulated loyalty points | +| Earn Rate | Points earned per currency unit spent | +| Redemption | Converting points to rewards | + +--- + +## Phase 3: Tactical Modeling + +### Accounts Bounded Context + +#### Aggregate: Account + +``` +Account Aggregate +├── Account (Aggregate Root) +│ ├── AccountID (Value Object) +│ ├── Balance (Value Object: Money) +│ ├── Status (Value Object: Active|Frozen|Closed) +│ ├── AccountType (Value Object: Standard|Credit) +│ ├── CreditLimit (Value Object: Money) +│ └── Holders[] (Entity) +│ ├── HolderID (Value Object) +│ ├── UserID (Value Object - reference to User aggregate) +│ └── Role (Value Object: Owner|Operator|Viewer) +│ +└── Invariants: + - Balance >= 0 for Standard accounts + - Balance >= -CreditLimit for Credit accounts + - At least one Holder with Owner role + - Cannot debit Frozen account + - Cannot modify Closed account +``` + +#### Value Objects + +```go +// AccountID - unique identifier +type AccountID struct { value uuid.UUID } + +// Money - monetary amount with currency +type Money struct { + amount int64 // cents + currency string // ISO 4217 +} + +// Status - account status +type Status struct { value string } +var ( + StatusActive = Status{"active"} + StatusFrozen = Status{"frozen"} + StatusClosed = Status{"closed"} +) + +// AccountType +type AccountType struct { value string } +var ( + AccountTypeStandard = AccountType{"standard"} + AccountTypeCredit = AccountType{"credit"} +) + +// Role - holder role +type Role struct { value string } +var ( + RoleOwner = Role{"owner"} + RoleOperator = Role{"operator"} + RoleViewer = Role{"viewer"} +) +``` + +### Transfers Bounded Context + +#### Aggregate: Transfer + +``` +Transfer Aggregate +├── Transfer (Aggregate Root) +│ ├── TransferID (Value Object) +│ ├── SourceAccountID (Value Object - reference only!) +│ ├── DestinationAccountID (Value Object - reference only!) +│ ├── Amount (Value Object: Money) +│ ├── Status (Value Object: Pending|Completed|Failed) +│ ├── FailureReason (Value Object, optional) +│ ├── InitiatedAt (timestamp) +│ └── CompletedAt (timestamp, optional) +│ +└── Invariants: + - Amount must be positive + - Source and Destination must be different + - Status transitions: Pending → Completed|Failed only + - Cannot modify after Completed or Failed +``` + +### Loyalty Bounded Context + +#### Aggregate: LoyaltyProgram + +``` +LoyaltyProgram Aggregate +├── LoyaltyProgram (Aggregate Root) +│ ├── ProgramID (Value Object) +│ ├── CustomerID (Value Object - reference only!) +│ ├── Tier (Value Object: Bronze|Silver|Gold|Platinum) +│ ├── Points (Value Object) +│ ├── EarnRate (Value Object) +│ └── PointsHistory[] (Value Object) +│ ├── Date +│ ├── Amount +│ ├── Type (Earned|Redeemed|Expired) +│ └── Description +│ +└── Invariants: + - Points >= 0 + - Tier determined by points thresholds + - Cannot redeem more points than available +``` + +--- + +## Phase 4: Invariants + +### Account Aggregate Invariants + +```go +// Account aggregate invariants: +// +// Invariant: Balance cannot be negative for standard accounts +// Enforced in: Withdraw(), constructor +// +// Invariant: Balance cannot exceed credit limit for credit accounts +// Enforced in: Withdraw(), constructor +// +// Invariant: Account must have at least one holder with OWNER role +// Enforced in: RemoveHolder(), constructor +// +// Invariant: Frozen account cannot process debit operations +// Enforced in: Withdraw() +// +// Invariant: Closed account cannot be modified +// Enforced in: all mutation methods +``` + +### Transfer Aggregate Invariants + +```go +// Transfer aggregate invariants: +// +// Invariant: Transfer amount must be positive +// Enforced in: constructor +// +// Invariant: Source and destination accounts must be different +// Enforced in: constructor +// +// Invariant: Status can only transition Pending → Completed or Pending → Failed +// Enforced in: Complete(), Fail() +// +// Invariant: Completed or Failed transfer cannot be modified +// Enforced in: all mutation methods +``` + +### LoyaltyProgram Aggregate Invariants + +```go +// LoyaltyProgram aggregate invariants: +// +// Invariant: Points balance cannot be negative +// Enforced in: RedeemPoints() +// +// Invariant: Tier is determined by points thresholds +// Enforced in: EarnPoints() (automatic upgrade), calculateTier() +// +// Invariant: Cannot redeem more points than available +// Enforced in: RedeemPoints() +``` + +--- + +## Phase 5: Generated Code + +### Account Aggregate + +```go +package accounts + +import ( + "errors" + "time" +) + +var ( + ErrInsufficientFunds = errors.New("insufficient funds") + ErrAccountFrozen = errors.New("account is frozen") + ErrAccountClosed = errors.New("account is closed") + ErrCannotRemoveLastOwner = errors.New("cannot remove last owner") + ErrNegativeAmount = errors.New("amount must be positive") + ErrCreditLimitExceeded = errors.New("credit limit exceeded") +) + +// AccountAggregate represents a bank account. +// +// Invariants: +// - Balance >= 0 for standard accounts +// - Balance >= -CreditLimit for credit accounts +// - At least one holder with OWNER role +// - Frozen account cannot process debit operations +// - Closed account cannot be modified +type AccountAggregate struct { + id AccountID + balance Money + holders []Holder + status Status + accountType AccountType + creditLimit Money + + createdAt time.Time + updatedAt time.Time + events []DomainEvent +} + +// NewAccount creates a new standard account. +func NewAccount(id AccountID, initialHolder Holder) (*AccountAggregate, error) { + if initialHolder.Role() != RoleOwner { + return nil, ErrCannotRemoveLastOwner + } + + return &AccountAggregate{ + id: id, + balance: MustMoney(0, "USD"), + holders: []Holder{initialHolder}, + status: StatusActive, + accountType: AccountTypeStandard, + creditLimit: MustMoney(0, "USD"), + createdAt: time.Now(), + updatedAt: time.Now(), + events: make([]DomainEvent, 0), + }, nil +} + +// NewCreditAccount creates a new credit account with limit. +func NewCreditAccount(id AccountID, initialHolder Holder, creditLimit Money) (*AccountAggregate, error) { + account, err := NewAccount(id, initialHolder) + if err != nil { + return nil, err + } + account.accountType = AccountTypeCredit + account.creditLimit = creditLimit + return account, nil +} + +func (a *AccountAggregate) ID() AccountID { return a.id } +func (a *AccountAggregate) Balance() Money { return a.balance } +func (a *AccountAggregate) Status() Status { return a.status } +func (a *AccountAggregate) AccountType() AccountType { return a.accountType } +func (a *AccountAggregate) CreditLimit() Money { return a.creditLimit } +func (a *AccountAggregate) CreatedAt() time.Time { return a.createdAt } +func (a *AccountAggregate) UpdatedAt() time.Time { return a.updatedAt } + +// Deposit adds money to the account. +func (a *AccountAggregate) Deposit(amount Money) error { + if a.status == StatusClosed { + return ErrAccountClosed + } + if amount.IsNegativeOrZero() { + return ErrNegativeAmount + } + + newBalance, err := a.balance.Add(amount) + if err != nil { + return err + } + + a.balance = newBalance + a.updatedAt = time.Now() + a.raise(DepositedEvent{AccountID: a.id, Amount: amount, NewBalance: a.balance}) + return nil +} + +// Withdraw removes money from the account. +func (a *AccountAggregate) Withdraw(amount Money) error { + if a.status == StatusClosed { + return ErrAccountClosed + } + if a.status == StatusFrozen { + return ErrAccountFrozen + } + if amount.IsNegativeOrZero() { + return ErrNegativeAmount + } + + newBalance, err := a.balance.Subtract(amount) + if err != nil { + return err + } + + // Check balance constraints based on account type + if a.accountType == AccountTypeStandard && newBalance.IsNegative() { + return ErrInsufficientFunds + } + if a.accountType == AccountTypeCredit && newBalance.IsNegative() { + if newBalance.Abs().GreaterThan(a.creditLimit) { + return ErrCreditLimitExceeded + } + } + + a.balance = newBalance + a.updatedAt = time.Now() + a.raise(WithdrawnEvent{AccountID: a.id, Amount: amount, NewBalance: a.balance}) + return nil +} + +// Freeze blocks debit operations on the account. +func (a *AccountAggregate) Freeze() error { + if a.status == StatusClosed { + return ErrAccountClosed + } + a.status = StatusFrozen + a.updatedAt = time.Now() + a.raise(AccountFrozenEvent{AccountID: a.id}) + return nil +} + +// Unfreeze allows debit operations again. +func (a *AccountAggregate) Unfreeze() error { + if a.status == StatusClosed { + return ErrAccountClosed + } + a.status = StatusActive + a.updatedAt = time.Now() + a.raise(AccountUnfrozenEvent{AccountID: a.id}) + return nil +} + +// AddHolder adds a new holder to the account. +func (a *AccountAggregate) AddHolder(holder Holder) error { + if a.status == StatusClosed { + return ErrAccountClosed + } + a.holders = append(a.holders, holder) + a.updatedAt = time.Now() + return nil +} + +// RemoveHolder removes a holder from the account. +func (a *AccountAggregate) RemoveHolder(holderID HolderID) error { + if a.status == StatusClosed { + return ErrAccountClosed + } + + // Check invariant: must keep at least one owner + remainingOwners := 0 + for _, h := range a.holders { + if h.ID() != holderID && h.Role() == RoleOwner { + remainingOwners++ + } + } + if remainingOwners == 0 { + return ErrCannotRemoveLastOwner + } + + // Remove holder + newHolders := make([]Holder, 0, len(a.holders)-1) + for _, h := range a.holders { + if h.ID() != holderID { + newHolders = append(newHolders, h) + } + } + a.holders = newHolders + a.updatedAt = time.Now() + return nil +} + +// Holders returns a copy of the holders list. +func (a *AccountAggregate) Holders() []Holder { + result := make([]Holder, len(a.holders)) + copy(result, a.holders) + return result +} + +// Events returns and clears pending domain events. +func (a *AccountAggregate) Events() []DomainEvent { + events := a.events + a.events = make([]DomainEvent, 0) + return events +} + +func (a *AccountAggregate) raise(event DomainEvent) { + a.events = append(a.events, event) +} +``` + +### Money Value Object + +```go +package accounts + +import ( + "errors" + "fmt" +) + +var ( + ErrMoneyCurrencyMismatch = errors.New("cannot operate on different currencies") + ErrMoneyCurrencyRequired = errors.New("currency is required") + ErrMoneyCurrencyInvalid = errors.New("currency must be 3-letter ISO code") +) + +// Money represents a monetary amount with currency. +// Immutable value object. +type Money struct { + amount int64 // cents/minor units + currency string // ISO 4217 +} + +// NewMoney creates a new Money value object. +func NewMoney(amount int64, currency string) (Money, error) { + if currency == "" { + return Money{}, ErrMoneyCurrencyRequired + } + if len(currency) != 3 { + return Money{}, ErrMoneyCurrencyInvalid + } + return Money{amount: amount, currency: currency}, nil +} + +// MustMoney creates Money or panics. Use in tests only. +func MustMoney(amount int64, currency string) Money { + m, err := NewMoney(amount, currency) + if err != nil { + panic(err) + } + return m +} + +// USD creates Money in US Dollars. +func USD(cents int64) Money { + return MustMoney(cents, "USD") +} + +func (m Money) Amount() int64 { return m.amount } +func (m Money) Currency() string { return m.currency } + +// Add returns a new Money with the sum. +func (m Money) Add(other Money) (Money, error) { + if m.currency != other.currency { + return Money{}, ErrMoneyCurrencyMismatch + } + return Money{amount: m.amount + other.amount, currency: m.currency}, nil +} + +// Subtract returns a new Money with the difference. +func (m Money) Subtract(other Money) (Money, error) { + if m.currency != other.currency { + return Money{}, ErrMoneyCurrencyMismatch + } + return Money{amount: m.amount - other.amount, currency: m.currency}, nil +} + +// Multiply returns Money multiplied by factor. +func (m Money) Multiply(factor int) Money { + return Money{amount: m.amount * int64(factor), currency: m.currency} +} + +func (m Money) IsNegative() bool { return m.amount < 0 } +func (m Money) IsNegativeOrZero() bool { return m.amount <= 0 } +func (m Money) IsPositive() bool { return m.amount > 0 } +func (m Money) IsZero() bool { return m.amount == 0 } + +func (m Money) Abs() Money { + if m.amount < 0 { + return Money{amount: -m.amount, currency: m.currency} + } + return m +} + +func (m Money) GreaterThan(other Money) bool { return m.amount > other.amount } +func (m Money) LessThan(other Money) bool { return m.amount < other.amount } + +func (m Money) Equals(other Money) bool { + return m.amount == other.amount && m.currency == other.currency +} + +func (m Money) String() string { + sign := "" + amount := m.amount + if amount < 0 { + sign = "-" + amount = -amount + } + dollars := amount / 100 + cents := amount % 100 + return fmt.Sprintf("%s%d.%02d %s", sign, dollars, cents, m.currency) +} +``` + +### Repository Interface + +```go +package accounts + +import ( + "context" + "errors" +) + +var ErrAccountNotFound = errors.New("account not found") + +// AccountRepository defines persistence operations for Account aggregates. +type AccountRepository interface { + Save(ctx context.Context, account *AccountAggregate) error + FindByID(ctx context.Context, id AccountID) (*AccountAggregate, error) + FindByHolderUserID(ctx context.Context, userID UserID) ([]*AccountAggregate, error) + Exists(ctx context.Context, id AccountID) (bool, error) + Delete(ctx context.Context, id AccountID) error +} +``` + +--- + +## Phase 6: Validation Checklist + +### Account Aggregate ✅ + +- [x] Aggregate Root is the only entry point +- [x] Child entities (Holder) accessed via aggregate methods +- [x] All changes through aggregate methods (Deposit, Withdraw, etc.) +- [x] Invariants documented in comments +- [x] Invariants checked in constructor +- [x] Invariants checked in all mutation methods +- [x] No direct references to other Aggregates (UserID is ID only) +- [x] One Aggregate = one transaction boundary + +### Value Objects ✅ + +- [x] Money is immutable (no setters) +- [x] Money validates in constructor +- [x] Money operations return new instances +- [x] Equals compares all fields +- [x] MustXxx variants for tests + +### Repository ✅ + +- [x] Interface in domain layer +- [x] Methods operate on aggregates +- [x] Context parameter for all methods +- [x] Domain-specific errors (ErrAccountNotFound) + +### Clean Architecture ✅ + +- [x] Domain layer has no external dependencies +- [x] Repository interface in domain, implementation in infrastructure +- [x] Domain events for cross-aggregate communication diff --git a/bin/.claude/skills/ddd-model/languages/java/error-handling.md b/bin/.claude/skills/ddd-model/languages/java/error-handling.md new file mode 100644 index 0000000..d54ffd9 --- /dev/null +++ b/bin/.claude/skills/ddd-model/languages/java/error-handling.md @@ -0,0 +1,781 @@ +# Error Handling in Java 21+ + +This guide covers error handling in Java projects following DDD principles, with emphasis on Result types and sealed interfaces. + +## Result Interface + +The fundamental pattern for error handling without exceptions: + +### Definition + +```java +package com.example.shared.result; + +/** + * Result represents either an error (left) or a value (right). + * Inspired by Either/Result from functional languages. + * + * @param Error type (must be sealed interface or final class) + * @param Success value type + */ +public sealed interface Result permits Failure, Success { + /** + * Applies function if result is success. + */ + Result map(java.util.function.Function fn); + + /** + * Chains Result-returning operations. + */ + Result flatMap(java.util.function.Function> fn); + + /** + * Applies error function if result is failure. + */ + Result mapError(java.util.function.Function fn); + + /** + * Pattern matching on Result. + */ + U match( + java.util.function.Function onError, + java.util.function.Function onSuccess + ); + + /** + * Returns value or throws if error. + */ + T orElseThrow(java.util.function.Function exceptionFn); + + /** + * Returns value or default if error. + */ + T orElse(T defaultValue); + + /** + * Success result (right side). + */ + static Result success(T value) { + return new Success<>(value); + } + + /** + * Failure result (left side). + */ + static Result failure(E error) { + return new Failure<>(error); + } + + /** + * Alias for success(). + */ + static Result ok(T value) { + return success(value); + } + + /** + * Alias for failure(). + */ + static Result err(E error) { + return failure(error); + } +} + +/** + * Success case - carries the successful value. + */ +final class Success implements Result { + private final T value; + + Success(T value) { + this.value = value; + } + + @Override + public Result map(java.util.function.Function fn) { + return new Success<>(fn.apply(value)); + } + + @Override + public Result flatMap(java.util.function.Function> fn) { + return fn.apply(value); + } + + @Override + public Result mapError(java.util.function.Function fn) { + return this; + } + + @Override + public U match( + java.util.function.Function onError, + java.util.function.Function onSuccess + ) { + return onSuccess.apply(value); + } + + @Override + public T orElseThrow(java.util.function.Function exceptionFn) { + return value; + } + + @Override + public T orElse(T defaultValue) { + return value; + } + + public T value() { + return value; + } + + @Override + public String toString() { + return "Success(" + value + ")"; + } +} + +/** + * Failure case - carries the error. + */ +final class Failure implements Result { + private final E error; + + Failure(E error) { + this.error = error; + } + + @Override + public Result map(java.util.function.Function fn) { + return new Failure<>(error); + } + + @Override + public Result flatMap(java.util.function.Function> fn) { + return new Failure<>(error); + } + + @Override + public Result mapError(java.util.function.Function fn) { + return new Failure<>(fn.apply(error)); + } + + @Override + public U match( + java.util.function.Function onError, + java.util.function.Function onSuccess + ) { + return onError.apply(error); + } + + @Override + public T orElseThrow(java.util.function.Function exceptionFn) { + throw exceptionFn.apply(error); + } + + @Override + public T orElse(T defaultValue) { + return defaultValue; + } + + public E error() { + return error; + } + + @Override + public String toString() { + return "Failure(" + error + ")"; + } +} +``` + +## Static Imports for Readability + +Define static import helpers in your domain: + +```java +// In com.example.shared.result.Results +public class Results { + private Results() {} + + public static Result ok(T value) { + return Result.success(value); + } + + public static Result fail(E error) { + return Result.failure(error); + } +} + +// Usage with static import +import static com.example.shared.result.Results.*; + +Result result = ok(account); +Result error = fail(new InsufficientFundsError()); +``` + +## Sealed Interfaces for Domain Errors + +Layer-specific errors as sealed interfaces: + +### Domain Layer Errors + +```java +package com.example.domain.account; + +/** + * Account domain errors - business rule violations. + */ +public sealed interface AccountError permits + InsufficientFundsError, + AccountClosedError, + InvalidAmountError, + AccountNotFoundError { + + String message(); +} + +public record InsufficientFundsError( + Money required, + Money available +) implements AccountError { + @Override + public String message() { + return String.format( + "Insufficient funds: required %s, available %s", + required, available + ); + } +} + +public record AccountClosedError( + AccountId accountId +) implements AccountError { + @Override + public String message() { + return "Account is closed: " + accountId; + } +} + +public record InvalidAmountError( + Money amount, + String reason +) implements AccountError { + @Override + public String message() { + return String.format("Invalid amount %s: %s", amount, reason); + } +} + +public record AccountNotFoundError( + AccountId accountId +) implements AccountError { + @Override + public String message() { + return "Account not found: " + accountId; + } +} +``` + +### Application Layer Errors + +```java +package com.example.application.account; + +/** + * Application layer errors - use case failures. + * Maps domain errors to application context. + */ +public sealed interface WithdrawError permits + InsufficientFundsError, + AccountLockedError, + RepositoryError, + ConcurrencyError { + + String message(); +} + +public record InsufficientFundsError( + String accountId, + java.math.BigDecimal required, + java.math.BigDecimal available +) implements WithdrawError { + @Override + public String message() { + return String.format( + "Cannot withdraw: insufficient funds in account %s", + accountId + ); + } +} + +public record AccountLockedError( + String accountId, + String reason +) implements WithdrawError { + @Override + public String message() { + return String.format("Account %s is locked: %s", accountId, reason); + } +} + +public record RepositoryError( + String cause +) implements WithdrawError { + @Override + public String message() { + return "Repository error: " + cause; + } +} + +public record ConcurrencyError( + String accountId +) implements WithdrawError { + @Override + public String message() { + return "Account was modified concurrently: " + accountId; + } +} +``` + +### Infrastructure Layer Errors + +```java +package com.example.infrastructure.persistence; + +/** + * Infrastructure errors - technical failures. + * Never leak to domain/application layer. + */ +public sealed interface PersistenceError permits + ConnectionError, + QueryError, + TransactionError, + DeserializationError { + + String message(); + Throwable cause(); +} + +public record ConnectionError( + String reason, + Throwable exception +) implements PersistenceError { + @Override + public String message() { + return "Database connection failed: " + reason; + } + + @Override + public Throwable cause() { + return exception; + } +} + +public record QueryError( + String sql, + Throwable exception +) implements PersistenceError { + @Override + public String message() { + return "Query execution failed: " + sql; + } + + @Override + public Throwable cause() { + return exception; + } +} + +public record TransactionError( + String reason, + Throwable exception +) implements PersistenceError { + @Override + public String message() { + return "Transaction failed: " + reason; + } + + @Override + public Throwable cause() { + return exception; + } +} + +public record DeserializationError( + String reason, + Throwable exception +) implements PersistenceError { + @Override + public String message() { + return "Deserialization failed: " + reason; + } + + @Override + public Throwable cause() { + return exception; + } +} +``` + +## Pattern Matching Examples + +### Switch Expression with Pattern Matching + +```java +public static void handleWithdrawalResult(Result result) { + switch (result) { + case Success(Account account) -> { + System.out.println("Withdrawal successful, new balance: " + account.balance()); + } + case Failure(InsufficientFundsError error) -> { + System.err.println(error.message()); + } + case Failure(AccountLockedError error) -> { + System.err.println("Please contact support: " + error.reason()); + } + case Failure(WithdrawError error) -> { + System.err.println("Unexpected error: " + error.message()); + } + } +} +``` + +### Map and FlatMap Chaining + +```java +public Result transfer( + AccountId fromId, + AccountId toId, + Money amount +) { + return accountRepository.findById(fromId) + .mapError(e -> new SourceAccountNotFound(fromId)) + .flatMap(fromAccount -> + accountRepository.findById(toId) + .mapError(e -> new DestinationAccountNotFound(toId)) + .flatMap(toAccount -> { + Result withdrawn = fromAccount.withdraw(amount); + if (withdrawn instanceof Failure failure) { + return Result.failure(new WithdrawalFailed(failure.error().message())); + } + + Result deposited = toAccount.deposit(amount); + if (deposited instanceof Failure failure) { + return Result.failure(new DepositFailed(failure.error().message())); + } + + return Result.success(new TransferRecord(fromId, toId, amount)); + }) + ); +} +``` + +### Match Expression for Conditional Logic + +```java +public String formatWithdrawalStatus(Result result) { + return result.match( + error -> "Failed: " + error.message(), + account -> "Success: New balance is " + account.balance().formatted() + ); +} +``` + +## Layer-Specific Error Transformation + +### Domain to Application Boundary + +```java +package com.example.application.account; + +import com.example.domain.account.AccountError; +import com.example.infrastructure.persistence.PersistenceError; + +public class WithdrawService { + private final AccountRepository repository; + + /** + * Maps domain errors to application errors. + * Infrastructure errors caught at boundary. + */ + public Result withdraw( + AccountId accountId, + Money amount + ) { + try { + // Repository might fail with infrastructure errors + return repository.findById(accountId) + .mapError(error -> mapDomainError(error)) + .flatMap(account -> + account.withdraw(amount) + .mapError(error -> mapDomainError(error)) + ); + } catch (Exception e) { + // Infrastructure exception caught at boundary + logger.error("Unexpected infrastructure error during withdrawal", e); + return Result.failure(new RepositoryError(e.getMessage())); + } + } + + private WithdrawError mapDomainError(AccountError error) { + return switch (error) { + case InsufficientFundsError e -> + new InsufficientFundsError( + e.required().toString(), + e.available().toString() + ); + case AccountClosedError e -> + new AccountLockedError(e.accountId().value(), "Account closed"); + case InvalidAmountError e -> + new RepositoryError(e.reason()); + case AccountNotFoundError e -> + new RepositoryError("Account not found: " + e.accountId()); + }; + } +} +``` + +### Application to Infrastructure Boundary + +```java +package com.example.infrastructure.persistence; + +import com.example.domain.account.Account; +import java.sql.Connection; + +public class JdbcAccountRepository implements AccountRepository { + + @Override + public Result findById(AccountId id) { + Connection conn = null; + try { + conn = dataSource.getConnection(); + Account account = executeQuery(conn, id); + return Result.success(account); + } catch (SQLException e) { + // Log original exception + logger.error("SQL error querying account: " + id, e); + return Result.failure(new RepositoryError( + "Database query failed: " + e.getMessage() + )); + } catch (Exception e) { + logger.error("Unexpected error deserializing account: " + id, e); + return Result.failure(new RepositoryError( + "Deserialization failed: " + e.getMessage() + )); + } finally { + if (conn != null) { + try { conn.close(); } catch (SQLException e) { + logger.warn("Error closing connection", e); + } + } + } + } + + private Account executeQuery(Connection conn, AccountId id) throws SQLException { + String sql = "SELECT * FROM accounts WHERE id = ?"; + try (var stmt = conn.prepareStatement(sql)) { + stmt.setString(1, id.value()); + var rs = stmt.executeQuery(); + if (!rs.next()) { + throw new SQLException("Account not found: " + id); + } + return mapRowToAccount(rs); + } + } +} +``` + +## Exception Boundaries + +### Infrastructure Layer Only Catches External Exceptions + +```java +package com.example.infrastructure.payment; + +import org.stripe.exception.StripeException; + +public class StripePaymentGateway implements PaymentGateway { + + @Override + public Result charge( + String token, + Money amount + ) { + try { + // Stripe API call + var charge = Stripe.Charges.create( + token, amount.centAmount() + ); + return Result.success(new PaymentConfirmation(charge.id)); + + } catch (StripeException e) { + // Log original exception + logger.error("Stripe API error charging payment", e); + + // Transform to domain error + PaymentError error = switch (e.getCode()) { + case "card_declined" -> + new CardDeclinedError(e.getMessage()); + case "rate_limit" -> + new PaymentUnavailableError("Rate limited by Stripe"); + default -> + new PaymentFailedError(e.getMessage()); + }; + + return Result.failure(error); + } + } +} +``` + +### Domain/Application Layers Never Catch Exceptions + +```java +// ❌ DON'T DO THIS in domain/application +public class Order { + public Result place() { + try { // ❌ No try/catch in domain + validate(); + return Result.success(null); + } catch (Exception e) { // ❌ Wrong + return Result.failure(new OrderError(...)); + } + } +} + +// ✅ DO THIS instead +public class Order { + public Result place() { + if (!isValid()) { // ✅ Guard clauses instead + return Result.failure(new InvalidOrderError(...)); + } + return Result.success(null); + } +} +``` + +## Logging Strategy + +### ERROR Level - Infrastructure Failures + +```java +// Database down, network error, external service error +logger.error("Database connection failed", e); // Log original exception +logger.error("Stripe API error during payment", stripeException); +logger.error("Kafka publishing failed for event: " + eventId, kafkaException); +``` + +### WARN Level - Business Rule Violations + +```java +// Insufficient funds, account closed, invalid state +logger.warn("Withdrawal rejected: insufficient funds in account " + accountId); +logger.warn("Order cannot be placed with empty line items"); +logger.warn("Transfer cancelled: destination account closed"); +``` + +### INFO Level - Normal Operations + +```java +// Expected business events +logger.info("Account created: " + accountId); +logger.info("Transfer completed: " + transferId + " from " + fromId + " to " + toId); +logger.info("Order placed: " + orderId + " for customer " + customerId); +``` + +### DEBUG Level - Detailed Flow + +```java +// Control flow details (not shown in production) +logger.debug("Starting account balance calculation for: " + accountId); +logger.debug("Processing " + lineItems.size() + " line items"); +``` + +### Logging at Layer Boundaries + +```java +public class WithdrawService { + public Result withdraw( + AccountId accountId, + Money amount + ) { + return repository.findById(accountId) + .mapError(domainError -> { + // Log domain error at application level + logger.warn("Account not found during withdrawal: " + accountId); + return mapToApplicationError(domainError); + }) + .flatMap(account -> + account.withdraw(amount) + .mapError(domainError -> { + if (domainError instanceof InsufficientFundsError e) { + logger.warn("Insufficient funds: required " + e.required() + + ", available " + e.available()); + } + return mapToApplicationError(domainError); + }) + ); + } +} + +public class JdbcAccountRepository implements AccountRepository { + @Override + public Result findById(AccountId id) { + try { + return executeQuery(id); + } catch (SQLException e) { + // Log technical error at infrastructure level + logger.error("SQL error querying account " + id, e); + return Result.failure(new RepositoryError("Query failed: " + e.getMessage())); + } + } +} +``` + +## Best Practices + +1. **Use sealed interfaces** for all domain/application errors +2. **Use records** for error data classes (immutable, concise) +3. **Add `message()` method** to every error type +4. **Transform errors at boundaries** (domain → application → infrastructure) +5. **Log original exceptions** before transformation +6. **Use static imports** for Result.success/failure +7. **Pattern match** instead of instanceof checks +8. **Avoid null returns** - use Result.failure instead +9. **Document invariants** that cause errors +10. **Never expose infrastructure errors** to domain/application + +## Testing Error Cases + +```java +public class WithdrawalTest { + @Test + void shouldRejectWithdrawalExceedingBalance() { + var account = Account.create( + accountId, + Money.usd(100) + ).orElseThrow(); + + Result result = account.withdraw(Money.usd(150)); + + assertThat(result).satisfies(r -> { + assertThat(r).isInstanceOf(Failure.class); + if (r instanceof Failure f) { + assertThat(f.error()).isInstanceOf(InsufficientFundsError.class); + } + }); + } + + @Test + void shouldMapDomainErrorToApplicationError() { + var domainError = new AccountNotFoundError(accountId); + var applicationError = service.mapDomainError(domainError); + + assertThat(applicationError) + .isInstanceOf(RepositoryError.class); + assertThat(applicationError.message()) + .contains("Account not found"); + } +} +``` diff --git a/bin/.claude/skills/ddd-model/languages/java/structure.md b/bin/.claude/skills/ddd-model/languages/java/structure.md new file mode 100644 index 0000000..5f36a61 --- /dev/null +++ b/bin/.claude/skills/ddd-model/languages/java/structure.md @@ -0,0 +1,685 @@ +# Project Structure for Java DDD + +This guide covers organizing a Java project following Clean Architecture and Domain-Driven Design principles. + +## Maven Structure + +Standard Maven project organization: + +``` +project-root/ +├── pom.xml +├── src/ +│ ├── main/ +│ │ └── java/ +│ │ └── com/example/ +│ │ ├── domain/ # Domain layer +│ │ ├── application/ # Application layer +│ │ ├── infrastructure/ # Infrastructure layer +│ │ └── shared/ # Shared utilities +│ └── test/ +│ └── java/ +│ └── com/example/ +│ ├── domain/ +│ ├── application/ +│ ├── infrastructure/ +│ └── shared/ +├── README.md +└── .gitignore +``` + +### Maven Dependencies Structure + +```xml + + 4.0.0 + com.example + banking-system + 1.0.0 + + + 21 + UTF-8 + + + + + + + + junit + junit-jupiter + 5.10.0 + test + + + + + org.slf4j + slf4j-api + 2.0.7 + + + + + org.postgresql + postgresql + 42.6.0 + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + 21 + + + + + +``` + +## Gradle Structure + +Gradle-based alternative: + +```gradle +plugins { + id 'java' +} + +java { + sourceCompatibility = '21' + targetCompatibility = '21' +} + +repositories { + mavenCentral() +} + +dependencies { + // Testing + testImplementation 'org.junit.jupiter:junit-jupiter:5.10.0' + + // Logging + implementation 'org.slf4j:slf4j-api:2.0.7' + runtimeOnly 'org.slf4j:slf4j-simple:2.0.7' + + // Database + implementation 'org.postgresql:postgresql:42.6.0' +} + +test { + useJUnitPlatform() +} +``` + +## Complete Directory Structure + +### Domain Layer + +``` +src/main/java/com/example/domain/ +├── account/ # Bounded context: Account +│ ├── Account.java # Aggregate root +│ ├── AccountId.java # Value object (ID) +│ ├── AccountStatus.java # Value object (enum-like) +│ ├── Money.java # Value object (shared across contexts) +│ ├── AccountError.java # Sealed interface for errors +│ ├── AccountRepository.java # Repository interface (domain contract) +│ └── DomainEventPublisher.java # Event publishing interface (optional) +│ +├── transfer/ # Bounded context: Transfer +│ ├── Transfer.java # Aggregate root +│ ├── TransferId.java # Value object +│ ├── TransferStatus.java # Status enum +│ ├── TransferError.java # Errors +│ ├── TransferRepository.java # Repository interface +│ └── TransferService.java # Domain service (if needed) +│ +├── shared/ +│ ├── result/ +│ │ ├── Result.java # Result interface +│ │ ├── Success.java # Success case +│ │ └── Failure.java # Failure case +│ └── DomainEvent.java # Base domain event +``` + +### Application Layer + +``` +src/main/java/com/example/application/ +├── account/ +│ ├── OpenAccountUseCase.java # One use case per file +│ ├── DepositMoneyUseCase.java +│ ├── WithdrawMoneyUseCase.java +│ ├── GetAccountBalanceUseCase.java +│ ├── dto/ +│ │ ├── OpenAccountRequest.java +│ │ ├── OpenAccountResponse.java +│ │ ├── DepositRequest.java +│ │ └── DepositResponse.java +│ └── AccountApplicationError.java # App-specific errors +│ +├── transfer/ +│ ├── TransferMoneyUseCase.java +│ ├── GetTransferStatusUseCase.java +│ ├── dto/ +│ │ ├── TransferRequest.java +│ │ └── TransferResponse.java +│ └── TransferApplicationError.java +│ +├── shared/ +│ ├── UseCase.java # Interface/base class for use cases +│ └── UnitOfWork.java # Transaction management interface +``` + +### Infrastructure Layer + +``` +src/main/java/com/example/infrastructure/ +├── persistence/ +│ ├── account/ +│ │ ├── JdbcAccountRepository.java # Implements AccountRepository +│ │ ├── AccountRowMapper.java # Database row mapping +│ │ └── AccountQueries.java # SQL constants +│ ├── transfer/ +│ │ ├── JdbcTransferRepository.java +│ │ └── TransferRowMapper.java +│ ├── connection/ +│ │ ├── ConnectionPool.java +│ │ └── DataSourceFactory.java +│ └── transaction/ +│ └── JdbcUnitOfWork.java # Transaction coordinator +│ +├── http/ +│ ├── handler/ +│ │ ├── account/ +│ │ │ ├── OpenAccountHandler.java +│ │ │ ├── WithdrawHandler.java +│ │ │ └── GetBalanceHandler.java +│ │ └── transfer/ +│ │ ├── TransferHandler.java +│ │ └── GetTransferStatusHandler.java +│ ├── router/ +│ │ └── ApiRouter.java # Route definition +│ ├── response/ +│ │ ├── SuccessResponse.java +│ │ └── ErrorResponse.java +│ └── middleware/ +│ ├── ErrorHandlingMiddleware.java +│ └── LoggingMiddleware.java +│ +├── event/ +│ ├── DomainEventPublisherImpl.java # Publishes domain events +│ ├── event-handlers/ +│ │ ├── AccountCreatedEventHandler.java +│ │ └── TransferCompletedEventHandler.java +│ └── EventDispatcher.java +│ +├── config/ +│ └── AppConfiguration.java # Dependency injection setup +│ +└── persistence/ + └── migrations/ + ├── V001__CreateAccountsTable.sql + └── V002__CreateTransfersTable.sql +``` + +### Test Structure + +``` +src/test/java/com/example/ +├── domain/ +│ ├── account/ +│ │ ├── AccountTest.java # Unit tests for Account +│ │ ├── MoneyTest.java +│ │ └── AccountRepositoryTest.java # Contract tests +│ └── transfer/ +│ ├── TransferTest.java +│ └── TransferRepositoryTest.java +│ +├── application/ +│ ├── account/ +│ │ ├── OpenAccountUseCaseTest.java +│ │ ├── WithdrawMoneyUseCaseTest.java +│ │ └── fixtures/ +│ │ ├── AccountFixture.java # Test data builders +│ │ └── MoneyFixture.java +│ └── transfer/ +│ └── TransferMoneyUseCaseTest.java +│ +├── infrastructure/ +│ ├── persistence/ +│ │ ├── JdbcAccountRepositoryTest.java # Integration tests +│ │ └── JdbcTransferRepositoryTest.java +│ └── http/ +│ └── OpenAccountHandlerTest.java +│ +└── acceptance/ + └── OpenAccountAcceptanceTest.java # End-to-end tests +``` + +## Three Organizational Approaches + +### Approach 1: BC-First (Recommended for Most Projects) + +Organize around Bounded Contexts: + +``` +src/main/java/com/example/ +├── account/ # BC 1 +│ ├── domain/ +│ │ ├── Account.java +│ │ ├── AccountError.java +│ │ └── AccountRepository.java +│ ├── application/ +│ │ ├── OpenAccountUseCase.java +│ │ └── dto/ +│ └── infrastructure/ +│ ├── persistence/ +│ │ └── JdbcAccountRepository.java +│ └── http/ +│ └── OpenAccountHandler.java +│ +├── transfer/ # BC 2 +│ ├── domain/ +│ ├── application/ +│ └── infrastructure/ +│ +└── shared/ # Shared across BCs + ├── result/ + │ └── Result.java + └── events/ +``` + +**Pros:** +- Clear BC boundaries +- Easy to navigate between layers within a context +- Natural place for context-specific configuration +- Facilitates team ownership per BC + +**Cons:** +- Duplication across contexts +- More code organization overhead + +### Approach 2: Tech-First (Better for Microservices) + +Organize by technical layer: + +``` +src/main/java/com/example/ +├── domain/ +│ ├── account/ +│ │ ├── Account.java +│ │ ├── AccountRepository.java +│ │ └── AccountError.java +│ └── transfer/ +│ +├── application/ +│ ├── account/ +│ │ ├── OpenAccountUseCase.java +│ │ └── dto/ +│ └── transfer/ +│ +├── infrastructure/ +│ ├── persistence/ +│ │ ├── account/ +│ │ │ └── JdbcAccountRepository.java +│ │ └── transfer/ +│ ├── http/ +│ │ ├── account/ +│ │ └── transfer/ +│ └── config/ +│ +└── shared/ +``` + +**Pros:** +- Clear layer separation +- Easy to review layer architecture +- Good for enforcing dependency rules + +**Cons:** +- Scattered BC concepts across files +- Harder to find all code for one feature + +### Approach 3: Hybrid (Best for Large Projects) + +Combine both approaches strategically: + +``` +src/main/java/com/example/ +├── domain/ # All domain objects, shared across project +│ ├── account/ +│ ├── transfer/ +│ └── shared/ +│ +├── application/ # All application services +│ ├── account/ +│ └── transfer/ +│ +├── infrastructure/ # Infrastructure organized by BC +│ ├── account/ +│ │ ├── persistence/ +│ │ └── http/ +│ ├── transfer/ +│ ├── config/ +│ └── shared/ +``` + +**Pros:** +- Emphasizes domain independence +- Clear infrastructure layer separation +- Good for large teams + +**Cons:** +- Two different organizational styles +- Requires discipline to maintain + +## One Use Case Per File + +### Recommended Structure + +``` +src/main/java/com/example/application/account/ +├── OpenAccountUseCase.java +├── DepositMoneyUseCase.java +├── WithdrawMoneyUseCase.java +├── GetAccountBalanceUseCase.java +├── CloseAccountUseCase.java +└── dto/ + ├── OpenAccountRequest.java + ├── OpenAccountResponse.java + ├── DepositRequest.java + └── DepositResponse.java +``` + +### Use Case File Template + +```java +package com.example.application.account; + +import com.example.domain.account.*; +import com.example.shared.result.Result; +import static com.example.shared.result.Result.success; +import static com.example.shared.result.Result.failure; + +/** + * OpenAccountUseCase - one file, one use case. + * + * Coordinates the opening of a new account: + * 1. Create Account aggregate + * 2. Persist via repository + * 3. Publish domain events + */ +public class OpenAccountUseCase { + private final AccountRepository accountRepository; + private final AccountIdGenerator idGenerator; + private final UnitOfWork unitOfWork; + + public OpenAccountUseCase( + AccountRepository accountRepository, + AccountIdGenerator idGenerator, + UnitOfWork unitOfWork + ) { + this.accountRepository = accountRepository; + this.idGenerator = idGenerator; + this.unitOfWork = unitOfWork; + } + + /** + * Execute the use case. + * + * @param request containing account opening parameters + * @return success with account ID, or failure with reason + */ + public Result execute( + OpenAccountRequest request + ) { + try { + // Phase 1: Create aggregate + AccountId accountId = idGenerator.generate(); + Result accountResult = Account.create( + accountId, + request.initialBalance(), + request.accountHolder() + ); + + if (accountResult instanceof Failure f) { + return failure(mapError(f.error())); + } + + Account account = ((Success) accountResult).value(); + + // Phase 2: Persist + return unitOfWork.withTransaction(() -> { + accountRepository.save(account); + + // Phase 3: Publish events + account.publishedEvents().forEach(event -> + eventPublisher.publish(event) + ); + + return success(new OpenAccountResponse( + account.id().value(), + account.balance() + )); + }); + + } catch (Exception e) { + logger.error("Unexpected error opening account", e); + return failure(new OpenAccountError.RepositoryError("Failed to save account")); + } + } + + private OpenAccountError mapError(AccountError error) { + return switch (error) { + case InvalidAmountError e -> + new OpenAccountError.InvalidInitialBalance(e.message()); + case InvalidAccountHolderError e -> + new OpenAccountError.InvalidHolder(e.message()); + default -> + new OpenAccountError.UnexpectedError(error.message()); + }; + } +} + +/** + * OpenAccountRequest - input DTO. + */ +public record OpenAccountRequest( + Money initialBalance, + String accountHolder +) { + public OpenAccountRequest { + if (initialBalance == null) { + throw new IllegalArgumentException("Initial balance required"); + } + if (accountHolder == null || accountHolder.isBlank()) { + throw new IllegalArgumentException("Account holder name required"); + } + } +} + +/** + * OpenAccountResponse - output DTO. + */ +public record OpenAccountResponse( + String accountId, + Money balance +) {} + +/** + * OpenAccountError - use case specific errors. + */ +public sealed interface OpenAccountError permits + InvalidInitialBalance, + InvalidHolder, + RepositoryError, + UnexpectedError { + String message(); +} + +public record InvalidInitialBalance(String reason) implements OpenAccountError { + @Override + public String message() { + return "Invalid initial balance: " + reason; + } +} + +// ... other error implementations +``` + +## Package Naming + +``` +com.example # Root +├── domain # Domain layer +│ ├── account # BC 1 domain +│ │ └── Account.java +│ └── transfer # BC 2 domain +│ └── Transfer.java +├── application # Application layer +│ ├── account # BC 1 use cases +│ │ └── OpenAccountUseCase.java +│ └── transfer # BC 2 use cases +│ └── TransferMoneyUseCase.java +├── infrastructure # Infrastructure layer +│ ├── persistence # Persistence adapters +│ │ ├── account # BC 1 persistence +│ │ └── transfer # BC 2 persistence +│ ├── http # HTTP adapters +│ │ ├── account # BC 1 handlers +│ │ └── transfer # BC 2 handlers +│ └── config # Configuration +│ └── AppConfiguration.java +└── shared # Shared across layers + ├── result # Result + ├── events # Domain events + └── exceptions # Shared exceptions (use sparingly) +``` + +## Example: Account BC Structure + +Complete example of one bounded context: + +``` +com/example/account/ +├── domain/ +│ ├── Account.java # Aggregate root +│ ├── AccountId.java # ID value object +│ ├── AccountStatus.java # Status value object +│ ├── AccountError.java # Sealed error interface +│ ├── AccountRepository.java # Repository interface +│ ├── DomainEvents.java # Domain events (AccountCreated, etc.) +│ └── AccountIdGenerator.java # Generator interface +│ +├── application/ +│ ├── OpenAccountUseCase.java +│ ├── DepositMoneyUseCase.java +│ ├── WithdrawMoneyUseCase.java +│ ├── GetAccountBalanceUseCase.java +│ ├── ApplicationError.java # App-level errors +│ ├── dto/ +│ │ ├── OpenAccountRequest.java +│ │ ├── OpenAccountResponse.java +│ │ ├── DepositRequest.java +│ │ └── DepositResponse.java +│ └── fixtures/ (test directory) +│ └── AccountFixture.java +│ +└── infrastructure/ + ├── persistence/ + │ ├── JdbcAccountRepository.java + │ ├── AccountRowMapper.java + │ └── AccountQueries.java + ├── http/ + │ ├── OpenAccountHandler.java + │ ├── DepositHandler.java + │ ├── WithdrawHandler.java + │ └── GetBalanceHandler.java + └── events/ + └── AccountEventHandlers.java +``` + +## Configuration Example + +```java +package com.example.infrastructure.config; + +public class AppConfiguration { + private final DataSource dataSource; + private final DomainEventPublisher eventPublisher; + + public AppConfiguration() { + this.dataSource = createDataSource(); + this.eventPublisher = createEventPublisher(); + } + + // Account BC dependencies + public AccountRepository accountRepository() { + return new JdbcAccountRepository(dataSource); + } + + public AccountIdGenerator accountIdGenerator() { + return new UuidAccountIdGenerator(); + } + + public OpenAccountUseCase openAccountUseCase() { + return new OpenAccountUseCase( + accountRepository(), + accountIdGenerator(), + unitOfWork() + ); + } + + // Transfer BC dependencies + public TransferRepository transferRepository() { + return new JdbcTransferRepository(dataSource); + } + + public TransferMoneyUseCase transferMoneyUseCase() { + return new TransferMoneyUseCase( + accountRepository(), + transferRepository(), + unitOfWork() + ); + } + + // Shared infrastructure + public UnitOfWork unitOfWork() { + return new JdbcUnitOfWork(dataSource); + } + + public DomainEventPublisher eventPublisher() { + return eventPublisher; + } + + private DataSource createDataSource() { + // Database pool configuration + return new HikariDataSource(); + } + + private DomainEventPublisher createEventPublisher() { + return new SimpleEventPublisher(); + } +} +``` + +## Best Practices + +1. **Organize by BC first** when starting a project +2. **One use case per file** in application layer +3. **Keep test directory structure** parallel to main +4. **Place DTOs near their use cases** (not in separate folder) +5. **Shared code in `shared` package** (Result, base classes) +6. **Database migrations** in dedicated folder +7. **Configuration at root** of infrastructure layer +8. **HTTP handlers** group by BC +9. **Repository implementations** group by BC +10. **No circular package dependencies** - enforce with checkstyle diff --git a/bin/.claude/skills/ddd-model/languages/java/style-guide.md b/bin/.claude/skills/ddd-model/languages/java/style-guide.md new file mode 100644 index 0000000..c2f3266 --- /dev/null +++ b/bin/.claude/skills/ddd-model/languages/java/style-guide.md @@ -0,0 +1,767 @@ +# Java 21+ Style Guide for DDD + +This guide covers Java conventions and modern language features for Domain-Driven Design implementations. + +## Records vs Classes for Value Objects + +### Use Records for Value Objects + +Records are perfect for immutable value objects with validation: + +```java +/** + * Use record for simple value object. + * Automatically generates equals, hashCode, toString. + */ +public record Money( + java.math.BigDecimal amount, + String currency +) { + /** + * Compact constructor performs validation. + */ + public Money { + if (amount == null) { + throw new IllegalArgumentException("Amount cannot be null"); + } + if (currency == null || currency.isBlank()) { + throw new IllegalArgumentException("Currency cannot be empty"); + } + if (amount.signum() < 0) { + throw new IllegalArgumentException("Amount cannot be negative"); + } + // Canonicalize to 2 decimal places + amount = amount.setScale(2, java.math.RoundingMode.HALF_UP); + } + + /** + * Static factory for common currencies. + */ + public static Money usd(long cents) { + return new Money( + java.math.BigDecimal.valueOf(cents).scaleByPowerOfTen(-2), + "USD" + ); + } + + public static Money eur(long cents) { + return new Money( + java.math.BigDecimal.valueOf(cents).scaleByPowerOfTen(-2), + "EUR" + ); + } + + /** + * MustXxx variant for tests (panics on error). + */ + public static Money mustUsd(String amount) { + try { + return usd(Long.parseLong(amount)); + } catch (NumberFormatException e) { + throw new AssertionError("Invalid money for test: " + amount, e); + } + } + + public boolean isNegativeOrZero() { + return amount.signum() <= 0; + } + + public boolean isPositive() { + return amount.signum() > 0; + } + + /** + * Domain operation: add money (must be same currency). + */ + public Result add(Money other) { + if (!currency.equals(other.currency)) { + return Result.failure( + new CurrencyMismatchError(currency, other.currency) + ); + } + return Result.success( + new Money(amount.add(other.amount), currency) + ); + } + + /** + * Domain operation: multiply by factor. + */ + public Money multiply(int factor) { + if (factor < 0) { + throw new IllegalArgumentException("Factor cannot be negative"); + } + return new Money( + amount.multiply(java.math.BigDecimal.valueOf(factor)), + currency + ); + } + + /** + * Formatted display. + */ + public String formatted() { + return String.format("%s %s", currency, amount); + } +} + +public sealed interface MoneyError permits CurrencyMismatchError { + String message(); +} + +public record CurrencyMismatchError( + String from, + String to +) implements MoneyError { + @Override + public String message() { + return String.format("Currency mismatch: %s vs %s", from, to); + } +} +``` + +### Use Classes for Aggregates/Entities + +Keep mutable aggregates and entities as classes for encapsulation: + +```java +/** + * Aggregate root - use class for mutability. + * Package-private constructor forces use of factory. + */ +public class Account { + private final AccountId id; + private Money balance; + private AccountStatus status; + private final java.time.Instant createdAt; + private java.time.Instant updatedAt; + + /** + * Private constructor - use factory method. + */ + private Account( + AccountId id, + Money initialBalance, + AccountStatus status, + java.time.Instant createdAt + ) { + this.id = id; + this.balance = initialBalance; + this.status = status; + this.createdAt = createdAt; + this.updatedAt = createdAt; + } + + /** + * Factory method in aggregate. + */ + public static Result create( + AccountId id, + Money initialBalance + ) { + if (initialBalance.isNegative()) { + return Result.failure( + new InvalidBalanceError(initialBalance) + ); + } + + Account account = new Account( + id, + initialBalance, + AccountStatus.ACTIVE, + java.time.Instant.now() + ); + + return Result.success(account); + } + + // ✅ Getters with accessor method names (not get prefix) + public AccountId id() { return id; } + public Money balance() { return balance; } + public AccountStatus status() { return status; } + + /** + * Invariant: Cannot withdraw from closed account + * Invariant: Cannot withdraw more than balance + */ + public Result withdraw(Money amount) { + // Guard: Check status + if (status == AccountStatus.CLOSED) { + return Result.failure(new AccountClosedError(id)); + } + + // Guard: Check amount + if (amount.isNegativeOrZero()) { + return Result.failure(new InvalidAmountError(amount)); + } + + // Guard: Check balance + if (balance.amount().compareTo(amount.amount()) < 0) { + return Result.failure( + new InsufficientFundsError(amount, balance) + ); + } + + // Execute state change + this.balance = new Money( + balance.amount().subtract(amount.amount()), + balance.currency() + ); + this.updatedAt = java.time.Instant.now(); + + return Result.success(null); + } + + // Equality based on ID (entity identity) + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Account account)) return false; + return Objects.equals(id, account.id); + } + + @Override + public int hashCode() { + return Objects.hash(id); + } + + @Override + public String toString() { + return "Account{" + + "id=" + id + + ", balance=" + balance + + ", status=" + status + + '}'; + } +} +``` + +## Sealed Interfaces for Type Hierarchies + +Use sealed interfaces for error types and domain concepts: + +```java +/** + * Sealed interface - only permitted implementations. + */ +public sealed interface AccountError permits + AccountClosedError, + InsufficientFundsError, + InvalidAmountError, + AccountNotFoundError { + String message(); +} + +/** + * Only this class can implement AccountError. + */ +public record AccountClosedError(AccountId id) implements AccountError { + @Override + public String message() { + return "Account closed: " + id; + } +} + +// More implementations... + +/** + * Sealed with final subclasses. + */ +public sealed interface OrderStatus permits + DraftStatus, + PlacedStatus, + ShippedStatus, + DeliveredStatus, + CancelledStatus { + String description(); +} + +public final record DraftStatus() implements OrderStatus { + @Override + public String description() { + return "Draft - being composed"; + } +} + +public final record PlacedStatus(java.time.Instant placedAt) implements OrderStatus { + @Override + public String description() { + return "Placed on " + placedAt; + } +} +``` + +## Pattern Matching + +### Instance Checking + +```java +// ❌ Old style +public void process(OrderError error) { + if (error instanceof InvalidAmountError) { + InvalidAmountError e = (InvalidAmountError) error; + System.out.println(e.amount()); + } +} + +// ✅ New style - pattern matching +public void process(OrderError error) { + if (error instanceof InvalidAmountError e) { + System.out.println(e.amount()); + } +} +``` + +### Switch with Pattern Matching + +```java +public String formatError(OrderError error) { + return switch (error) { + case InvalidAmountError e -> + "Invalid amount: " + e.amount(); + case LineItemNotFoundError e -> + "Line item not found: " + e.lineItemId(); + case OrderAlreadyPlacedError e -> + "Order already placed: " + e.orderNumber(); + case OrderCancelledError e -> + "Order cancelled: " + e.orderNumber(); + case EmptyOrderError -> + "Cannot place order with no items"; + }; +} + +/** + * Pattern matching with Result. + */ +public void handleResult(Result result) { + switch (result) { + case Success(Order order) -> { + System.out.println("Order created: " + order.orderNumber()); + } + case Failure(InvalidAmountError e) -> { + logger.warn("Invalid amount in order: " + e.amount()); + } + case Failure(EmptyOrderError) -> { + logger.warn("User attempted to place empty order"); + } + case Failure(OrderError e) -> { + logger.error("Unexpected order error: " + e.message()); + } + } +} +``` + +### Record Pattern Matching + +```java +// Record patterns (Java 21+) +public void processTransfer(Object obj) { + if (obj instanceof Transfer( + Money amount, + AccountId from, + AccountId to + )) { + System.out.println("Transfer " + amount + " from " + from + " to " + to); + } +} + +// In switch expressions +public String describeTransfer(Object obj) { + return switch (obj) { + case Transfer(Money amount, AccountId from, AccountId to) -> + String.format("Transfer %s from %s to %s", amount, from, to); + case Withdrawal(Money amount, AccountId account) -> + String.format("Withdrawal %s from %s", amount, account); + default -> "Unknown operation"; + }; +} +``` + +## Static Imports + +Use static imports for readability: + +```java +// ❌ Verbose without static import +Result account = Result.success(newAccount); + +// ✅ With static imports +import static com.example.shared.result.Result.success; +import static com.example.shared.result.Result.failure; + +Result account = success(newAccount); +Result error = failure(new AccountClosedError(id)); +``` + +### Import Aliases + +```java +// For classes with same name in different packages +import com.example.domain.account.AccountError; +import com.example.application.account.AccountError as AppAccountError; +``` + +## Private Constructors + Public Static Factories + +### Aggregate Pattern + +```java +/** + * Aggregates use private constructor + public factory. + * Ensures validation always happens. + */ +public class Order { + private final OrderNumber number; + private final CustomerId customerId; + private final List lineItems; + private OrderStatus status; + + /** + * Private - access only via factory. + */ + private Order( + OrderNumber number, + CustomerId customerId, + OrderStatus status + ) { + this.number = number; + this.customerId = customerId; + this.lineItems = new ArrayList<>(); + this.status = status; + } + + /** + * Create new order (in DRAFT status). + */ + public static Result create( + OrderNumber number, + CustomerId customerId + ) { + // Validation before construction + if (number == null) { + return Result.failure(new InvalidOrderNumberError()); + } + if (customerId == null) { + return Result.failure(new InvalidCustomerIdError()); + } + + Order order = new Order(number, customerId, OrderStatus.DRAFT); + return Result.success(order); + } + + /** + * Reconstruct from database (internal use). + * Skips validation since data is from trusted source. + */ + static Order reconstruct( + OrderNumber number, + CustomerId customerId, + List lineItems, + OrderStatus status + ) { + Order order = new Order(number, customerId, status); + order.lineItems.addAll(lineItems); + return order; + } + + /** + * For tests - panics on error. + */ + public static Order mustCreate(String number, String customerId) { + return create( + new OrderNumber(number), + new CustomerId(customerId) + ).orElseThrow(e -> + new AssertionError("Failed to create test order: " + e.message()) + ); + } +} +``` + +### Value Object Pattern + +```java +/** + * Value object - sealed to prevent subclassing. + */ +public final record Money( + java.math.BigDecimal amount, + String currency +) { + /** + * Compact constructor validates. + */ + public Money { + if (amount == null || currency == null) { + throw new IllegalArgumentException("Cannot be null"); + } + if (amount.signum() < 0) { + throw new IllegalArgumentException("Amount cannot be negative"); + } + } + + /** + * Factory for USD (common case). + */ + public static Money usd(long cents) { + return new Money( + java.math.BigDecimal.valueOf(cents, 2), + "USD" + ); + } + + /** + * Factory for arbitrary currency. + */ + public static Result of(String amount, String currency) { + try { + return Result.success( + new Money(new java.math.BigDecimal(amount), currency) + ); + } catch (NumberFormatException e) { + return Result.failure(new InvalidMoneyFormatError(amount)); + } + } + + /** + * Must-variant for tests. + */ + public static Money mustUsd(long cents) { + return usd(cents); + } + + public static Money mustOf(String amount, String currency) { + return of(amount, currency) + .orElseThrow(e -> + new AssertionError("Test money construction failed: " + e.message()) + ); + } +} +``` + +## Package-Private for Entities + +Hide child entities from outside aggregates: + +```java +package com.example.domain.order; + +/** + * Aggregate root - public. + */ +public class Order { + private final List lineItems; + + /** + * Public factory - creates Order and its children. + */ + public static Result create(...) { + return Result.success(new Order(...)); + } + + /** + * Package-private - only Order and other aggregate classes access this. + */ + public Result addLineItem(...) { + // Internal validation + OrderLine line = new OrderLine(...); // Package-private constructor + lineItems.add(line); + return Result.success(null); + } +} + +/** + * Entity - package-private constructor. + * Only Order aggregate can create it. + */ +public class OrderLine { + private final OrderLineId id; + private final ProductId productId; + private int quantity; + + /** + * Package-private - only Order can use. + */ + OrderLine( + OrderLineId id, + ProductId productId, + String name, + Money unitPrice, + int quantity + ) { + this.id = id; + this.productId = productId; + this.quantity = quantity; + } + + /** + * Package-private factory - used by Order. + */ + static OrderLine create( + OrderLineId id, + ProductId productId, + String name, + Money unitPrice, + int quantity + ) { + if (quantity <= 0) { + throw new IllegalArgumentException("Quantity must be positive"); + } + return new OrderLine(id, productId, name, unitPrice, quantity); + } + + // ✅ Package-private accessor (no public getter for modification) + int getQuantity() { return quantity; } + + /** + * Package-private mutation - only Order calls this. + */ + void updateQuantity(int newQuantity) { + if (newQuantity <= 0) { + throw new IllegalArgumentException("Quantity must be positive"); + } + this.quantity = newQuantity; + } +} + +// Outside the package - cannot access OrderLine directly +Order order = Order.create(...).success(); +// ❌ OrderLine line = new OrderLine(...); // Compile error +// ❌ order.lineItems().get(0); // No public getter + +// ✅ Access through Order aggregate only +order.addLineItem(...); +order.removeLineItem(...); +``` + +## Naming Conventions + +| Element | Convention | Example | +|---------|-----------|---------| +| Class | PascalCase | `Account`, `Order`, `Transfer` | +| Record | PascalCase | `Money`, `OrderNumber`, `CustomerId` | +| Interface | PascalCase | `Repository`, `AccountError`, `OrderStatus` | +| Variable | camelCase | `accountId`, `initialBalance`, `orderNumber` | +| Constant | UPPER_SNAKE_CASE | `MAX_AMOUNT`, `DEFAULT_CURRENCY` | +| Method | camelCase (no get/set prefix) | `balance()`, `withdraw()`, `transfer()` | +| Enum | PascalCase values | `ACTIVE`, `DRAFT`, `PLACED` | + +## Accessor Methods (Property-Style) + +```java +// ✅ Preferred in DDD - property-style accessors +public class Account { + private Money balance; + + public Money balance() { // Property name, not getBalance() + return balance; + } + + public AccountId id() { // Not getId() + return id; + } +} + +// ❌ Avoid getter/setter naming in domain +public class Account { + public Money getBalance() { ... } // Too verbose for domain + public void setBalance(Money m) { ... } // Never use setters +} +``` + +## Immutability + +```java +// ✅ Immutable record +public record Money( + java.math.BigDecimal amount, + String currency +) { + // No setters, fields are final automatically +} + +// ✅ Defensive copy for mutable collection +public class Order { + private List lineItems; + + public List lineItems() { + return List.copyOf(lineItems); // Returns unmodifiable copy + } + + public void addLineItem(OrderLine line) { + lineItems.add(line); // Internal modification only + } +} + +// ❌ Avoid direct exposure of mutable collections +public class Order { + public List getLineItems() { // Caller could modify! + return lineItems; + } +} +``` + +## Documentation + +```java +/** + * Order aggregate root. + * + * Represents a customer's order with multiple line items. + * State transitions: DRAFT -> PLACED -> SHIPPED -> DELIVERED + * Or: DRAFT/PLACED -> CANCELLED + * + * Invariant: Cannot modify order after PLACED + * Invariant: Cannot place order with zero line items + * Invariant: Order total = sum of line item totals + * + * @see OrderLine + * @see OrderRepository + */ +public class Order { + + /** + * Creates new order in DRAFT status. + * + * @param number unique order identifier + * @param customerId customer who placed the order + * @return new Order wrapped in Result + * @throws nothing - errors are in Result type + */ + public static Result create( + OrderNumber number, + CustomerId customerId + ) { + ... + } + + /** + * Places the order (transitions DRAFT -> PLACED). + * + * Invariant: Can only place DRAFT orders + * Invariant: Order must have at least one line item + * + * @return success if placed, failure with reason if not + */ + public Result place() { + ... + } +} +``` + +## Best Practices Summary + +1. **Use records** for value objects, IDs, errors +2. **Use classes** for aggregates and entities +3. **Private constructors** on aggregates, **public factories** for validation +4. **Sealed interfaces** for closed type hierarchies (errors, statuses) +5. **Pattern matching** instead of instanceof + casting +6. **Static imports** for Result.success/failure and common factories +7. **Property-style accessors** (method name = property name) +8. **No setters** in domain objects (return Result instead) +9. **Defensive copies** for mutable collections +10. **Package-private** for child entities (enforce aggregate boundaries) +11. **Final records** by default +12. **Compact constructors** for validation +13. **Document invariants** in Javadoc +14. **Use Result** instead of throwing exceptions diff --git a/bin/.claude/skills/ddd-model/languages/java/templates/Aggregate.java.md b/bin/.claude/skills/ddd-model/languages/java/templates/Aggregate.java.md new file mode 100644 index 0000000..cbfcc37 --- /dev/null +++ b/bin/.claude/skills/ddd-model/languages/java/templates/Aggregate.java.md @@ -0,0 +1,687 @@ +# Aggregate Root Template (Java) + +Template for creating aggregate roots in Java 21+ following DDD and Clean Architecture principles. + +## Pattern + +An aggregate root is: +- An entity with a public static factory method returning `Result` +- A mutable class with a private constructor +- The only entry point to modify its children +- Enforces all invariants through mutation methods +- All mutations return `Result` types instead of throwing exceptions + +## Complete Example: Account Aggregate + +```java +package com.example.domain.account; + +import com.example.shared.result.Result; +import java.time.Instant; +import java.util.List; +import java.util.ArrayList; +import java.util.Collections; +import static com.example.shared.result.Result.success; +import static com.example.shared.result.Result.failure; + +/** + * Account aggregate root. + * + * Represents a bank account with balance, status, and holders. + * Enforces all invariants around account operations. + * + * Invariant: Balance cannot be negative for standard accounts + * Invariant: Balance cannot exceed credit limit for credit accounts + * Invariant: Account must have at least one holder with OWNER role + * Invariant: Frozen account cannot process debit operations + * Invariant: Closed account cannot be modified + */ +public class Account { + private final AccountId id; + private Money balance; + private AccountStatus status; + private final AccountType accountType; + private Money creditLimit; + private final List holders; + private final Instant createdAt; + private Instant updatedAt; + private final List events; + + /** + * Private constructor - only accessible via factory methods. + * Ensures validation always occurs before construction. + */ + private Account( + AccountId id, + Money initialBalance, + AccountType accountType, + Money creditLimit, + AccountStatus status, + List holders, + Instant createdAt + ) { + this.id = id; + this.balance = initialBalance; + this.accountType = accountType; + this.creditLimit = creditLimit; + this.status = status; + this.holders = new ArrayList<>(holders); + this.createdAt = createdAt; + this.updatedAt = createdAt; + this.events = new ArrayList<>(); + } + + /** + * Factory method to create a new standard account. + * + * Validates: + * - Initial balance must be non-negative + * - Initial holder must have OWNER role + * + * @param id unique account identifier + * @param initialBalance starting balance (must be >= 0) + * @param owner initial owner holder + * @return success with created account, or failure with reason + */ + public static Result create( + AccountId id, + Money initialBalance, + AccountHolder owner + ) { + // Guard: Validate initial balance + if (initialBalance == null) { + return failure(new InvalidAccountDataError("Initial balance cannot be null")); + } + if (initialBalance.isNegative()) { + return failure(new InvalidAccountDataError("Initial balance cannot be negative")); + } + + // Guard: Validate owner role + if (owner == null) { + return failure(new InvalidAccountDataError("Owner cannot be null")); + } + if (!owner.role().equals(AccountRole.OWNER)) { + return failure(new InvalidAccountDataError("Initial holder must have OWNER role")); + } + + // Construct aggregate + Account account = new Account( + id, + initialBalance, + AccountType.STANDARD, + Money.zero("USD"), // Standard accounts have no credit + AccountStatus.ACTIVE, + List.of(owner), + Instant.now() + ); + + // Raise domain event + account.raise(new AccountCreatedEvent(id, initialBalance)); + + return success(account); + } + + /** + * Factory method to create a new credit account with limit. + * + * @param id unique account identifier + * @param initialBalance starting balance + * @param owner initial owner holder + * @param creditLimit maximum credit allowed + * @return success with created account, or failure with reason + */ + public static Result createCredit( + AccountId id, + Money initialBalance, + AccountHolder owner, + Money creditLimit + ) { + // Guard: Validate credit limit + if (creditLimit == null || creditLimit.isNegative()) { + return failure( + new InvalidAccountDataError("Credit limit must be non-negative") + ); + } + + // Reuse standard account creation validation + var standardAccount = create(id, initialBalance, owner); + if (standardAccount instanceof Failure f) { + return (Result) (Object) standardAccount; + } + + // Construct credit account from standard + Account account = ((Success) (Object) standardAccount).value(); + account.accountType = AccountType.CREDIT; + account.creditLimit = creditLimit; + + return success(account); + } + + /** + * Reconstruct aggregate from database (internal use only). + * Skips validation since data is from trusted source. + */ + static Account reconstruct( + AccountId id, + Money balance, + AccountType accountType, + Money creditLimit, + AccountStatus status, + List holders, + Instant createdAt, + Instant updatedAt + ) { + Account account = new Account( + id, + balance, + accountType, + creditLimit, + status, + holders, + createdAt + ); + account.updatedAt = updatedAt; + return account; + } + + /** + * Deposit money into account. + * + * Invariant: Cannot deposit to closed account + * Invariant: Amount must be positive + */ + public Result deposit(Money amount) { + // Guard: Check status + if (status == AccountStatus.CLOSED) { + return failure(new AccountClosedError(id)); + } + + // Guard: Check amount + if (amount == null) { + return failure(new InvalidOperationError("Amount cannot be null")); + } + if (amount.isNegativeOrZero()) { + return failure(new InvalidAmountError(amount, "Amount must be positive")); + } + + // Execute state change + try { + this.balance = balance.add(amount); + } catch (Exception e) { + return failure(new InvalidOperationError("Currency mismatch during deposit")); + } + + this.updatedAt = Instant.now(); + raise(new DepositedEvent(id, amount, balance)); + + return success(null); + } + + /** + * Withdraw money from account. + * + * Invariant: Cannot withdraw from closed account + * Invariant: Cannot withdraw from frozen account + * Invariant: Balance must stay >= 0 for standard accounts + * Invariant: Balance must stay >= -creditLimit for credit accounts + */ + public Result withdraw(Money amount) { + // Guard: Check status + if (status == AccountStatus.CLOSED) { + return failure(new AccountClosedError(id)); + } + if (status == AccountStatus.FROZEN) { + return failure(new AccountFrozenError(id)); + } + + // Guard: Check amount + if (amount == null) { + return failure(new InvalidOperationError("Amount cannot be null")); + } + if (amount.isNegativeOrZero()) { + return failure(new InvalidAmountError(amount, "Amount must be positive")); + } + + // Guard: Check balance constraints + try { + Money newBalance = balance.subtract(amount); + + if (accountType == AccountType.STANDARD && newBalance.isNegative()) { + return failure( + new InsufficientFundsError(amount, balance) + ); + } + + if (accountType == AccountType.CREDIT) { + if (newBalance.negate().greaterThan(creditLimit)) { + return failure(new CreditLimitExceededError(creditLimit, newBalance)); + } + } + + // Execute state change + this.balance = newBalance; + this.updatedAt = Instant.now(); + raise(new WithdrawnEvent(id, amount, balance)); + + return success(null); + + } catch (Exception e) { + return failure(new InvalidOperationError("Currency mismatch during withdrawal")); + } + } + + /** + * Freeze account (blocks debit operations). + * + * Invariant: Cannot freeze closed account + */ + public Result freeze() { + if (status == AccountStatus.CLOSED) { + return failure(new AccountClosedError(id)); + } + + this.status = AccountStatus.FROZEN; + this.updatedAt = Instant.now(); + raise(new AccountFrozenEvent(id)); + + return success(null); + } + + /** + * Unfreeze account (allows debit operations again). + * + * Invariant: Cannot unfreeze closed account + */ + public Result unfreeze() { + if (status == AccountStatus.CLOSED) { + return failure(new AccountClosedError(id)); + } + + this.status = AccountStatus.ACTIVE; + this.updatedAt = Instant.now(); + raise(new AccountUnfrozenEvent(id)); + + return success(null); + } + + /** + * Close account (prevents all future modifications). + */ + public Result close() { + if (status == AccountStatus.CLOSED) { + return failure(new AlreadyClosedError(id)); + } + + this.status = AccountStatus.CLOSED; + this.updatedAt = Instant.now(); + raise(new AccountClosedEvent(id)); + + return success(null); + } + + /** + * Add a new holder to account. + * + * Invariant: Cannot add to closed account + */ + public Result addHolder(AccountHolder holder) { + if (status == AccountStatus.CLOSED) { + return failure(new AccountClosedError(id)); + } + + if (holder == null) { + return failure(new InvalidAccountDataError("Holder cannot be null")); + } + + // Check if holder already exists + if (holders.stream().anyMatch(h -> h.id().equals(holder.id()))) { + return failure(new DuplicateHolderError(holder.id())); + } + + holders.add(holder); + this.updatedAt = Instant.now(); + raise(new HolderAddedEvent(id, holder.id())); + + return success(null); + } + + /** + * Remove a holder from account. + * + * Invariant: Cannot remove last OWNER + * Invariant: Cannot modify closed account + */ + public Result removeHolder(AccountHolderId holderId) { + if (status == AccountStatus.CLOSED) { + return failure(new AccountClosedError(id)); + } + + // Find the holder + AccountHolder holderToRemove = holders.stream() + .filter(h -> h.id().equals(holderId)) + .findFirst() + .orElse(null); + + if (holderToRemove == null) { + return failure(new HolderNotFoundError(holderId)); + } + + // Guard: Check invariant - must have at least one OWNER + long ownerCount = holders.stream() + .filter(h -> h.role() == AccountRole.OWNER && !h.id().equals(holderId)) + .count(); + + if (ownerCount == 0) { + return failure(new CannotRemoveLastOwnerError(id)); + } + + // Remove holder + holders.remove(holderToRemove); + this.updatedAt = Instant.now(); + raise(new HolderRemovedEvent(id, holderId)); + + return success(null); + } + + // ================== Getters (property-style accessors) ================== + + public AccountId id() { + return id; + } + + public Money balance() { + return balance; + } + + public AccountStatus status() { + return status; + } + + public AccountType accountType() { + return accountType; + } + + public Money creditLimit() { + return creditLimit; + } + + public List holders() { + return Collections.unmodifiableList(holders); + } + + public Instant createdAt() { + return createdAt; + } + + public Instant updatedAt() { + return updatedAt; + } + + /** + * Get and clear pending domain events. + * Call after persisting to ensure events are published only once. + */ + public List events() { + List result = new ArrayList<>(events); + events.clear(); + return result; + } + + // ================== Private Helper Methods ================== + + private void raise(DomainEvent event) { + events.add(event); + } + + // ================== Equality & Hash Code ================== + + /** + * Equality based on aggregate ID (entity identity). + */ + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Account account)) return false; + return id.equals(account.id); + } + + @Override + public int hashCode() { + return id.hashCode(); + } + + @Override + public String toString() { + return "Account{" + + "id=" + id + + ", balance=" + balance + + ", status=" + status + + ", holders=" + holders.size() + + '}'; + } +} +``` + +## Domain Events + +```java +package com.example.domain.account; + +import java.time.Instant; + +/** + * Base class for domain events. + * Raised when aggregate state changes. + */ +public sealed interface DomainEvent permits + AccountCreatedEvent, + DepositedEvent, + WithdrawnEvent, + AccountFrozenEvent, + AccountUnfrozenEvent, + AccountClosedEvent, + HolderAddedEvent, + HolderRemovedEvent { + + AccountId aggregateId(); + Instant occurredAt(); +} + +public record AccountCreatedEvent( + AccountId id, + Money initialBalance +) implements DomainEvent { + @Override + public AccountId aggregateId() { return id; } + + @Override + public Instant occurredAt() { return Instant.now(); } +} + +public record DepositedEvent( + AccountId id, + Money amount, + Money newBalance +) implements DomainEvent { + @Override + public AccountId aggregateId() { return id; } + + @Override + public Instant occurredAt() { return Instant.now(); } +} + +public record WithdrawnEvent( + AccountId id, + Money amount, + Money newBalance +) implements DomainEvent { + @Override + public AccountId aggregateId() { return id; } + + @Override + public Instant occurredAt() { return Instant.now(); } +} + +// Additional events... +``` + +## Domain Errors (Sealed Interface) + +```java +package com.example.domain.account; + +/** + * Account domain errors - business rule violations. + * Use sealed interface to restrict implementations and enable pattern matching. + */ +public sealed interface AccountError permits + InvalidAccountDataError, + AccountClosedError, + AccountFrozenError, + InvalidAmountError, + InvalidOperationError, + InsufficientFundsError, + CreditLimitExceededError, + CannotRemoveLastOwnerError, + HolderNotFoundError, + DuplicateHolderError, + AlreadyClosedError { + + String message(); +} + +public record InvalidAccountDataError(String reason) implements AccountError { + @Override + public String message() { + return "Invalid account data: " + reason; + } +} + +public record AccountClosedError(AccountId id) implements AccountError { + @Override + public String message() { + return "Account is closed: " + id; + } +} + +public record AccountFrozenError(AccountId id) implements AccountError { + @Override + public String message() { + return "Account is frozen: " + id; + } +} + +public record InvalidAmountError(Money amount, String reason) implements AccountError { + @Override + public String message() { + return String.format("Invalid amount %s: %s", amount, reason); + } +} + +public record InsufficientFundsError(Money required, Money available) implements AccountError { + @Override + public String message() { + return String.format( + "Insufficient funds: required %s, available %s", + required, available + ); + } +} + +public record CreditLimitExceededError(Money limit, Money newBalance) implements AccountError { + @Override + public String message() { + return String.format( + "Credit limit exceeded: limit %s, would be %s", + limit, newBalance + ); + } +} + +public record CannotRemoveLastOwnerError(AccountId id) implements AccountError { + @Override + public String message() { + return "Cannot remove last owner from account: " + id; + } +} + +// Additional error types... +``` + +## Key Features + +1. **Private Constructor**: Only accessible via factory methods +2. **Public Static Factory**: Returns `Result` for validation +3. **Invariant Documentation**: All invariants documented in comments +4. **Guard Clauses**: Check invariants before state changes +5. **Result Returns**: All mutation methods return `Result` instead of throwing +6. **Domain Events**: Raise events when state changes for eventually consistent communication +7. **ID-Based Equality**: Aggregates equal if their IDs are equal +8. **Immutable Collections**: Return defensive copies of internal collections +9. **Temporal Tracking**: Track creation and update times +10. **Sealed Errors**: Use sealed interfaces for type-safe error handling + +## Testing Pattern + +```java +public class AccountTest { + @Test + void shouldCreateAccountSuccessfully() { + var result = Account.create( + new AccountId("acc-123"), + Money.usd(100_00), // 100 USD + new AccountHolder(...) + ); + + assertThat(result).satisfies(r -> { + assertThat(r).isInstanceOf(Success.class); + if (r instanceof Success s) { + assertThat(s.value().balance()).isEqualTo(Money.usd(100_00)); + } + }); + } + + @Test + void shouldRejectNegativeInitialBalance() { + var result = Account.create( + new AccountId("acc-123"), + Money.usd(-100_00), // Negative! + new AccountHolder(...) + ); + + assertThat(result).satisfies(r -> { + assertThat(r).isInstanceOf(Failure.class); + if (r instanceof Failure f) { + assertThat(f.error()) + .isInstanceOf(InvalidAccountDataError.class); + } + }); + } + + @Test + void shouldPreventWithdrawalFromClosedAccount() { + var account = Account.create(...) + .orElseThrow(e -> new AssertionError("Setup failed")); + + account.close(); // Close first + var result = account.withdraw(Money.usd(10_00)); + + assertThat(result).satisfies(r -> { + assertThat(r).isInstanceOf(Failure.class); + if (r instanceof Failure f) { + assertThat(f.error()) + .isInstanceOf(AccountClosedError.class); + } + }); + } +} +``` + +## Notes + +- Use `Money.zero()`, `Money.usd()`, `Money.eur()` factory methods for convenience +- Raise domain events **before** returning success so they're captured +- Document all invariants in the class Javadoc +- Use `static` reconstruct method for database hydration (bypasses validation) +- Always use `instanceof Success` and `instanceof Failure` for pattern matching +- Defensive copy collections in getters using `Collections.unmodifiableList()` diff --git a/bin/.claude/skills/ddd-model/languages/java/templates/Entity.java.md b/bin/.claude/skills/ddd-model/languages/java/templates/Entity.java.md new file mode 100644 index 0000000..ef1dddc --- /dev/null +++ b/bin/.claude/skills/ddd-model/languages/java/templates/Entity.java.md @@ -0,0 +1,600 @@ +# Entity Template (Java) + +Template for creating child entities within aggregates in Java 21+. + +## Pattern + +An entity is: +- A mutable object that exists only within an aggregate boundary +- Package-private constructor (not accessible from other packages) +- Static package-private factory method for validation +- Equality based on its ID (entity identity) +- Only accessible through aggregate root methods +- Cannot be directly instantiated outside its aggregate + +## Example 1: AccountHolder Entity + +```java +package com.example.domain.account; + +import java.util.Objects; + +/** + * AccountHolder entity - a child of Account aggregate. + * + * Represents a person with access rights to an account. + * Only Account aggregate can create and modify holders. + * + * Package-private - not accessible outside this package. + * Equality based on holder ID. + */ +public class AccountHolder { + private final AccountHolderId id; + private final String name; + private final AccountRole role; + private final String emailAddress; + + /** + * Package-private constructor - only Account can use. + * Validation done in static factory. + */ + AccountHolder( + AccountHolderId id, + String name, + AccountRole role, + String emailAddress + ) { + this.id = id; + this.name = name; + this.role = role; + this.emailAddress = emailAddress; + } + + /** + * Static package-private factory - validates data. + * Used by Account aggregate when creating/adding holders. + * + * @param id unique holder identifier + * @param name holder's full name + * @param role access role (OWNER, OPERATOR, VIEWER) + * @param emailAddress contact email + * @return holder if valid, exception if invalid + */ + static AccountHolder create( + AccountHolderId id, + String name, + AccountRole role, + String emailAddress + ) { + // Guard: Validate ID + if (id == null) { + throw new IllegalArgumentException("Holder ID cannot be null"); + } + + // Guard: Validate name + if (name == null || name.isBlank()) { + throw new IllegalArgumentException("Holder name cannot be empty"); + } + if (name.length() > 100) { + throw new IllegalArgumentException("Holder name too long (max 100 chars)"); + } + + // Guard: Validate role + if (role == null) { + throw new IllegalArgumentException("Role cannot be null"); + } + + // Guard: Validate email + if (emailAddress == null || emailAddress.isBlank()) { + throw new IllegalArgumentException("Email cannot be empty"); + } + if (!isValidEmail(emailAddress)) { + throw new IllegalArgumentException("Invalid email format: " + emailAddress); + } + + return new AccountHolder(id, name, role, emailAddress); + } + + // ================== Getters ================== + + public AccountHolderId id() { + return id; + } + + public String name() { + return name; + } + + public AccountRole role() { + return role; + } + + public String emailAddress() { + return emailAddress; + } + + // ================== Package-Private Mutations ================== + + /** + * Package-private - only Account aggregate can change role. + * Used when promoting/demoting holders. + */ + void changeRole(AccountRole newRole) { + if (newRole == null) { + throw new IllegalArgumentException("Role cannot be null"); + } + // In reality, this would be: this.role = newRole; + // But role should be immutable, so prefer creating new instance + // or use a separate RoleChange method that returns Result + } + + // ================== Equality & Hash Code ================== + + /** + * Equality based on holder ID (entity identity). + * Two holders are equal if they have the same ID, + * even if other attributes differ. + */ + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof AccountHolder that)) return false; + return id.equals(that.id); + } + + @Override + public int hashCode() { + return Objects.hash(id); + } + + @Override + public String toString() { + return "AccountHolder{" + + "id=" + id + + ", name='" + name + '\'' + + ", role=" + role + + '}'; + } + + // ================== Private Helper Methods ================== + + private static boolean isValidEmail(String email) { + // Simple email validation + return email.matches("^[A-Za-z0-9+_.-]+@(.+)$"); + } +} +``` + +## Example 2: OrderLine Entity + +```java +package com.example.domain.order; + +import java.util.Objects; + +/** + * OrderLine entity - a child of Order aggregate. + * + * Represents one line item in an order. + * Multiple OrderLines compose an Order. + * + * Package-private - only Order aggregate can create/access. + * Equality based on line item ID. + * + * Invariant: Quantity must be positive + * Invariant: Unit price must be non-negative + */ +public class OrderLine { + private final OrderLineId id; + private final ProductId productId; + private final String productName; + private final Money unitPrice; + private int quantity; + + /** + * Package-private constructor. + * Used by OrderLine.create() factory and Order.reconstruct(). + */ + OrderLine( + OrderLineId id, + ProductId productId, + String productName, + Money unitPrice, + int quantity + ) { + this.id = id; + this.productId = productId; + this.productName = productName; + this.unitPrice = unitPrice; + this.quantity = quantity; + } + + /** + * Static package-private factory with validation. + * Called by Order when adding line items. + */ + static OrderLine create( + OrderLineId id, + ProductId productId, + String productName, + Money unitPrice, + int quantity + ) { + // Guard: Validate ID + if (id == null || productId == null) { + throw new IllegalArgumentException("IDs cannot be null"); + } + + // Guard: Validate name + if (productName == null || productName.isBlank()) { + throw new IllegalArgumentException("Product name cannot be empty"); + } + + // Guard: Validate unit price + if (unitPrice == null || unitPrice.isNegative()) { + throw new IllegalArgumentException("Unit price cannot be negative"); + } + + // Invariant: Quantity must be positive + if (quantity <= 0) { + throw new IllegalArgumentException("Quantity must be positive"); + } + + return new OrderLine(id, productId, productName, unitPrice, quantity); + } + + // ================== Getters ================== + + public OrderLineId id() { + return id; + } + + public ProductId productId() { + return productId; + } + + public String productName() { + return productName; + } + + public Money unitPrice() { + return unitPrice; + } + + public int quantity() { + return quantity; + } + + /** + * Calculate total for this line (unitPrice × quantity). + */ + public Money total() { + return unitPrice.multiply(quantity); + } + + // ================== Package-Private Mutations ================== + + /** + * Package-private - only Order aggregate can call. + * Updates quantity after validation. + * + * Invariant: New quantity must be positive + */ + void updateQuantity(int newQuantity) { + if (newQuantity <= 0) { + throw new IllegalArgumentException("Quantity must be positive"); + } + this.quantity = newQuantity; + } + + /** + * Package-private - only Order can remove entire line. + */ + void remove() { + // Mark as removed or actually remove from parent + // Implementation depends on how Order tracks removals + } + + // ================== Equality & Hash Code ================== + + /** + * Equality based on OrderLineId (entity identity). + */ + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof OrderLine that)) return false; + return id.equals(that.id); + } + + @Override + public int hashCode() { + return Objects.hash(id); + } + + @Override + public String toString() { + return "OrderLine{" + + "id=" + id + + ", productName='" + productName + '\'' + + ", quantity=" + quantity + + ", unitPrice=" + unitPrice + + '}'; + } +} +``` + +## Using Entities in Aggregates + +```java +package com.example.domain.order; + +import com.example.shared.result.Result; +import static com.example.shared.result.Result.success; +import static com.example.shared.result.Result.failure; + +public class Order { + private final OrderId id; + private final CustomerId customerId; + private final List lineItems; + private OrderStatus status; + + // ... constructor, factory, etc ... + + /** + * Add a line item to the order. + * + * Invariant: Cannot modify PLACED orders + * Invariant: Line cannot be duplicate product (or can it?) + */ + public Result addLineItem( + OrderLineId lineId, + ProductId productId, + String productName, + Money unitPrice, + int quantity + ) { + // Guard: Check status + if (status != OrderStatus.DRAFT) { + return failure( + new CannotModifyOrderError(id, "Only DRAFT orders can be modified") + ); + } + + // Guard: Validate line - throws exceptions, caller wraps if needed + try { + OrderLine line = OrderLine.create( + lineId, + productId, + productName, + unitPrice, + quantity + ); + + // Check for duplicate product + if (lineItems.stream() + .anyMatch(l -> l.productId().equals(productId))) { + return failure( + new DuplicateProductError(productId) + ); + } + + lineItems.add(line); + return success(null); + + } catch (IllegalArgumentException e) { + return failure( + new InvalidLineItemError(e.getMessage()) + ); + } + } + + /** + * Update quantity of a line item. + */ + public Result updateLineQuantity( + OrderLineId lineId, + int newQuantity + ) { + if (status != OrderStatus.DRAFT) { + return failure( + new CannotModifyOrderError(id, "Only DRAFT orders can be modified") + ); + } + + OrderLine line = lineItems.stream() + .filter(l -> l.id().equals(lineId)) + .findFirst() + .orElse(null); + + if (line == null) { + return failure(new LineNotFoundError(lineId)); + } + + try { + line.updateQuantity(newQuantity); + return success(null); + } catch (IllegalArgumentException e) { + return failure( + new InvalidQuantityError(newQuantity, e.getMessage()) + ); + } + } + + /** + * Remove a line item. + */ + public Result removeLineItem(OrderLineId lineId) { + if (status != OrderStatus.DRAFT) { + return failure( + new CannotModifyOrderError(id, "Only DRAFT orders can be modified") + ); + } + + boolean removed = lineItems.removeIf(l -> l.id().equals(lineId)); + if (!removed) { + return failure(new LineNotFoundError(lineId)); + } + + return success(null); + } + + /** + * Get immutable view of line items. + */ + public List lineItems() { + return Collections.unmodifiableList(lineItems); + } + + /** + * Calculate order total. + */ + public Money total() { + return lineItems.stream() + .map(OrderLine::total) + .reduce(Money.zero("USD"), Money::add); + } +} +``` + +## Value Object IDs for Entities + +```java +package com.example.domain.account; + +import java.util.Objects; +import java.util.UUID; + +/** + * Value object for AccountHolder identity. + * Every entity needs a unique ID represented as a value object. + */ +public final record AccountHolderId(String value) { + public AccountHolderId { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("AccountHolderId cannot be blank"); + } + } + + public static AccountHolderId random() { + return new AccountHolderId(UUID.randomUUID().toString()); + } + + @Override + public String toString() { + return value; + } +} + +public final record OrderLineId(String value) { + public OrderLineId { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("OrderLineId cannot be blank"); + } + } + + public static OrderLineId random() { + return new OrderLineId(UUID.randomUUID().toString()); + } +} +``` + +## Package-Private Visibility Example + +```java +// In com.example.domain.order package + +// ✅ CORRECT: Can access OrderLine from Order in same package +public class Order { + public void addLineItem(OrderLine line) { // Package-private constructor + lineItems.add(line); // OK + } +} + +// ❌ WRONG: Cannot access OrderLine from outside package +// In com.example.application.order package +public class CreateOrderUseCase { + public void execute(OrderLineData lineData) { + // OrderLine line = new OrderLine(...); // Compile error! + // Must go through Order aggregate + order.addLineItem(...); // ✅ Correct + } +} +``` + +## Testing Entities + +```java +public class OrderLineTest { + @Test + void shouldCreateLineWithValidData() { + OrderLine line = OrderLine.create( + OrderLineId.random(), + new ProductId("prod-123"), + "Widget", + Money.usd(10_00), // $10 + 5 + ); + + assertThat(line.quantity()).isEqualTo(5); + assertThat(line.total()).isEqualTo(Money.usd(50_00)); // $50 + } + + @Test + void shouldRejectZeroQuantity() { + assertThatThrownBy(() -> + OrderLine.create( + OrderLineId.random(), + new ProductId("prod-123"), + "Widget", + Money.usd(10_00), + 0 // Invalid + ) + ).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Quantity must be positive"); + } + + @Test + void shouldEquateByIdOnly() { + OrderLineId id = OrderLineId.random(); + OrderLine line1 = OrderLine.create(id, productId, "A", price, 5); + OrderLine line2 = OrderLine.create(id, productId, "B", price, 10); + + // Same ID = equal (other fields don't matter) + assertThat(line1).isEqualTo(line2); + } + + @Test + void shouldUpdateQuantity() { + OrderLine line = OrderLine.create(...); + line.updateQuantity(10); + + assertThat(line.quantity()).isEqualTo(10); + } +} +``` + +## Key Features + +1. **Package-Private Constructor**: Only aggregate can create +2. **Static Factory Method**: Validates before construction, can throw exceptions +3. **ID-Based Equality**: Two entities equal if their IDs are equal +4. **Package-Private Mutations**: Only aggregate can modify +5. **No Getters for Collections**: Return defensive copies or immutable views +6. **Invariant Enforcement**: Validate in factory and mutation methods +7. **Immutable Fields**: All fields effectively final (or truly final) +8. **Aggregate Access Only**: Never instantiate outside aggregate package +9. **Domain-Specific Types**: Use value objects for IDs and important concepts +10. **Clear Ownership**: Always document which aggregate owns this entity + +## Best Practices + +- Mark class `public` but constructor `package-private` (no explicit modifier) +- Use `static` factory with same visibility (package-private) for validation +- Throw `IllegalArgumentException` in entity factories (aggregates wrap in Result) +- Override `equals()` and `hashCode()` based on entity ID only +- Never expose mutable collections from entities +- Document in Javadoc which aggregate owns this entity +- Use `final` on value fields when possible +- Keep entities focused on a single responsibility +- Don't create entities with complex validation - that's the aggregate's job diff --git a/bin/.claude/skills/ddd-model/languages/java/templates/Repository.java.md b/bin/.claude/skills/ddd-model/languages/java/templates/Repository.java.md new file mode 100644 index 0000000..b59b79e --- /dev/null +++ b/bin/.claude/skills/ddd-model/languages/java/templates/Repository.java.md @@ -0,0 +1,721 @@ +# Repository Template (Java) + +Template for creating repositories in Java 21+ following DDD and Clean Architecture. + +## Pattern + +A repository: +- Is an interface in the domain layer (no implementation details) +- Provides methods to persist and retrieve aggregates +- All methods return `Result` types for safety +- Methods are named after domain concepts (not CRUD) +- Implementation lives in infrastructure layer +- Acts as a collection-like interface for aggregates + +## Domain Layer: Interface + +```java +package com.example.domain.account; + +import com.example.shared.result.Result; +import java.util.List; + +/** + * AccountRepository defines persistence contract for Account aggregates. + * + * Lives in domain layer - no infrastructure dependencies. + * Implementation in infrastructure layer. + * + * All methods return Result for safe error handling. + * Domain layer never has hidden failures - all errors explicit in type. + */ +public interface AccountRepository { + /** + * Save an account (create or update). + * + * @param account the aggregate to persist + * @return success if saved, failure with reason + */ + Result save(Account account); + + /** + * Retrieve account by ID. + * + * @param id the account identifier + * @return success with account if found, failure if not found + */ + Result findById(AccountId id); + + /** + * Retrieve all accounts for a holder. + * + * @param userId the user ID to search for + * @return success with list (possibly empty), failure if query fails + */ + Result> findByHolderUserId(UserId userId); + + /** + * Check if account exists. + * + * @param id the account identifier + * @return success with boolean, failure if query fails + */ + Result exists(AccountId id); + + /** + * Delete account (soft or hard). + * + * @param id the account to delete + * @return success if deleted, failure otherwise + */ + Result delete(AccountId id); +} + +/** + * Repository error type - infrastructure failures. + * Sealed interface for type-safe error handling. + */ +public sealed interface RepositoryError permits + NotFoundError, + ConflictError, + ConnectionError, + SerializationError, + UnexpectedError { + + String message(); +} + +public record NotFoundError( + String entityType, + String entityId +) implements RepositoryError { + @Override + public String message() { + return String.format("%s not found: %s", entityType, entityId); + } +} + +public record ConflictError( + String reason, + String entityId +) implements RepositoryError { + @Override + public String message() { + return String.format("Conflict: %s (ID: %s)", reason, entityId); + } +} + +public record ConnectionError( + String reason, + Throwable cause +) implements RepositoryError { + @Override + public String message() { + return "Database connection failed: " + reason; + } +} + +public record SerializationError( + String reason, + Throwable cause +) implements RepositoryError { + @Override + public String message() { + return "Serialization error: " + reason; + } +} + +public record UnexpectedError( + String reason, + Throwable cause +) implements RepositoryError { + @Override + public String message() { + return "Unexpected error: " + reason; + } +} +``` + +## Infrastructure Layer: JDBC Implementation + +```java +package com.example.infrastructure.persistence.account; + +import com.example.domain.account.*; +import com.example.shared.result.Result; +import static com.example.shared.result.Result.success; +import static com.example.shared.result.Result.failure; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import java.sql.*; +import java.util.*; +import javax.sql.DataSource; + +/** + * JdbcAccountRepository - JDBC implementation of AccountRepository. + * + * Lives in infrastructure layer - handles all database details. + * Translates between domain objects and relational schema. + * + * Exception handling: catches SQL exceptions, transforms to domain errors. + * Logging: logs technical errors, not business rule violations. + */ +public class JdbcAccountRepository implements AccountRepository { + private static final Logger logger = LoggerFactory.getLogger(JdbcAccountRepository.class); + private final DataSource dataSource; + private final AccountRowMapper rowMapper; + + public JdbcAccountRepository(DataSource dataSource, AccountRowMapper rowMapper) { + this.dataSource = Objects.requireNonNull(dataSource); + this.rowMapper = Objects.requireNonNull(rowMapper); + } + + /** + * Save account to database (insert or update). + */ + @Override + public Result save(Account account) { + String sql = "INSERT INTO accounts (id, balance, status, account_type, " + + "credit_limit, created_at, updated_at) " + + "VALUES (?, ?, ?, ?, ?, ?, ?) " + + "ON CONFLICT(id) DO UPDATE SET " + + "balance = EXCLUDED.balance, status = EXCLUDED.status, " + + "updated_at = EXCLUDED.updated_at"; + + Connection conn = null; + try { + conn = dataSource.getConnection(); + + // Start transaction + conn.setAutoCommit(false); + + try (var stmt = conn.prepareStatement(sql)) { + stmt.setString(1, account.id().value()); + stmt.setBigDecimal(2, account.balance().amount()); + stmt.setString(3, account.status().toString()); + stmt.setString(4, account.accountType().toString()); + stmt.setBigDecimal(5, account.creditLimit().amount()); + stmt.setTimestamp(6, Timestamp.from(account.createdAt())); + stmt.setTimestamp(7, Timestamp.from(account.updatedAt())); + + stmt.executeUpdate(); + } + + // Persist holders + saveholders(conn, account.id(), account.holders()); + + // Commit transaction + conn.commit(); + + return success(null); + + } catch (SQLException e) { + if (conn != null) { + try { conn.rollback(); } catch (SQLException rollbackEx) { + logger.warn("Rollback failed", rollbackEx); + } + } + logger.error("SQL error saving account " + account.id().value(), e); + return failure( + new ConnectionError("Failed to save account: " + e.getMessage(), e) + ); + } finally { + if (conn != null) { + try { conn.close(); } catch (SQLException e) { + logger.warn("Error closing connection", e); + } + } + } + } + + /** + * Find account by ID. + */ + @Override + public Result findById(AccountId id) { + String sql = "SELECT * FROM accounts WHERE id = ?"; + Connection conn = null; + + try { + conn = dataSource.getConnection(); + + try (var stmt = conn.prepareStatement(sql)) { + stmt.setString(1, id.value()); + var rs = stmt.executeQuery(); + + if (!rs.next()) { + return failure(new NotFoundError("Account", id.value())); + } + + Account account = rowMapper.mapRow(rs); + + // Load holders + List holders = loadHolders(conn, id); + account = Account.reconstruct( + account.id(), + account.balance(), + account.accountType(), + account.creditLimit(), + account.status(), + holders, + account.createdAt(), + account.updatedAt() + ); + + return success(account); + } + } catch (SQLException e) { + logger.error("SQL error finding account " + id.value(), e); + return failure( + new ConnectionError("Failed to find account: " + e.getMessage(), e) + ); + } finally { + if (conn != null) { + try { conn.close(); } catch (SQLException e) { + logger.warn("Error closing connection", e); + } + } + } + } + + /** + * Find all accounts for a user. + */ + @Override + public Result> findByHolderUserId(UserId userId) { + String sql = "SELECT DISTINCT a.* FROM accounts a " + + "JOIN account_holders h ON a.id = h.account_id " + + "WHERE h.user_id = ? " + + "ORDER BY a.created_at DESC"; + + Connection conn = null; + try { + conn = dataSource.getConnection(); + + try (var stmt = conn.prepareStatement(sql)) { + stmt.setString(1, userId.value()); + var rs = stmt.executeQuery(); + + List accounts = new ArrayList<>(); + while (rs.next()) { + Account account = rowMapper.mapRow(rs); + + // Load holders + List holders = loadHolders(conn, account.id()); + account = Account.reconstruct( + account.id(), + account.balance(), + account.accountType(), + account.creditLimit(), + account.status(), + holders, + account.createdAt(), + account.updatedAt() + ); + + accounts.add(account); + } + + return success(accounts); + } + } catch (SQLException e) { + logger.error("SQL error finding accounts for user " + userId.value(), e); + return failure( + new ConnectionError("Failed to find accounts: " + e.getMessage(), e) + ); + } finally { + if (conn != null) { + try { conn.close(); } catch (SQLException e) { + logger.warn("Error closing connection", e); + } + } + } + } + + /** + * Check if account exists. + */ + @Override + public Result exists(AccountId id) { + String sql = "SELECT 1 FROM accounts WHERE id = ?"; + Connection conn = null; + + try { + conn = dataSource.getConnection(); + + try (var stmt = conn.prepareStatement(sql)) { + stmt.setString(1, id.value()); + var rs = stmt.executeQuery(); + return success(rs.next()); + } + } catch (SQLException e) { + logger.error("SQL error checking account existence for " + id.value(), e); + return failure( + new ConnectionError("Failed to check account: " + e.getMessage(), e) + ); + } finally { + if (conn != null) { + try { conn.close(); } catch (SQLException e) { + logger.warn("Error closing connection", e); + } + } + } + } + + /** + * Delete account. + */ + @Override + public Result delete(AccountId id) { + String sql = "DELETE FROM accounts WHERE id = ?"; + Connection conn = null; + + try { + conn = dataSource.getConnection(); + conn.setAutoCommit(false); + + try (var stmt = conn.prepareStatement(sql)) { + stmt.setString(1, id.value()); + int deleted = stmt.executeUpdate(); + + if (deleted == 0) { + conn.rollback(); + return failure(new NotFoundError("Account", id.value())); + } + + // Delete holders too + deleteHolders(conn, id); + + conn.commit(); + return success(null); + + } catch (SQLException e) { + conn.rollback(); + throw e; + } + } catch (SQLException e) { + logger.error("SQL error deleting account " + id.value(), e); + return failure( + new ConnectionError("Failed to delete account: " + e.getMessage(), e) + ); + } finally { + if (conn != null) { + try { conn.close(); } catch (SQLException e) { + logger.warn("Error closing connection", e); + } + } + } + } + + // ================== Private Helpers ================== + + private void saveholders(Connection conn, AccountId accountId, List holders) + throws SQLException { + String deleteOldSql = "DELETE FROM account_holders WHERE account_id = ?"; + try (var stmt = conn.prepareStatement(deleteOldSql)) { + stmt.setString(1, accountId.value()); + stmt.executeUpdate(); + } + + String insertSql = "INSERT INTO account_holders (account_id, holder_id, name, role, email) " + + "VALUES (?, ?, ?, ?, ?)"; + try (var stmt = conn.prepareStatement(insertSql)) { + for (AccountHolder holder : holders) { + stmt.setString(1, accountId.value()); + stmt.setString(2, holder.id().value()); + stmt.setString(3, holder.name()); + stmt.setString(4, holder.role().toString()); + stmt.setString(5, holder.emailAddress()); + stmt.executeUpdate(); + } + } + } + + private List loadHolders(Connection conn, AccountId accountId) + throws SQLException { + String sql = "SELECT * FROM account_holders WHERE account_id = ? ORDER BY name"; + List holders = new ArrayList<>(); + + try (var stmt = conn.prepareStatement(sql)) { + stmt.setString(1, accountId.value()); + var rs = stmt.executeQuery(); + + while (rs.next()) { + AccountHolder holder = AccountHolder.create( + new AccountHolderId(rs.getString("holder_id")), + rs.getString("name"), + AccountRole.valueOf(rs.getString("role")), + rs.getString("email") + ); + holders.add(holder); + } + } + + return holders; + } + + private void deleteHolders(Connection conn, AccountId accountId) throws SQLException { + String sql = "DELETE FROM account_holders WHERE account_id = ?"; + try (var stmt = conn.prepareStatement(sql)) { + stmt.setString(1, accountId.value()); + stmt.executeUpdate(); + } + } +} +``` + +## Row Mapper for Hydration + +```java +package com.example.infrastructure.persistence.account; + +import com.example.domain.account.*; +import java.sql.ResultSet; +import java.sql.SQLException; + +/** + * AccountRowMapper - maps SQL result sets to Account aggregates. + * + * Part of infrastructure layer - handles database schema details. + * Separated from repository for testability and single responsibility. + */ +public class AccountRowMapper { + /** + * Map a single database row to Account aggregate. + * Note: Loads main account data but not child holders. + */ + public Account mapRow(ResultSet rs) throws SQLException { + AccountId id = new AccountId(rs.getString("id")); + Money balance = new Money( + rs.getBigDecimal("balance"), + "USD" // Assume currency column or hardcode + ); + AccountStatus status = AccountStatus.valueOf(rs.getString("status")); + AccountType type = AccountType.valueOf(rs.getString("account_type")); + Money creditLimit = new Money( + rs.getBigDecimal("credit_limit"), + "USD" + ); + + java.time.Instant createdAt = rs.getTimestamp("created_at") + .toInstant(); + java.time.Instant updatedAt = rs.getTimestamp("updated_at") + .toInstant(); + + // Return empty aggregate - holders loaded separately + return Account.reconstruct( + id, + balance, + type, + creditLimit, + status, + List.of(), // Holders loaded separately + createdAt, + updatedAt + ); + } +} +``` + +## SQL Queries as Constants + +```java +package com.example.infrastructure.persistence.account; + +/** + * AccountQueries - SQL constants for accounts. + * + * Centralizes SQL to make schema changes easier. + * Easier to unit test and review SQL. + */ +public class AccountQueries { + private AccountQueries() {} + + public static final String SAVE_ACCOUNT = + "INSERT INTO accounts (id, balance, status, account_type, credit_limit, " + + "created_at, updated_at) " + + "VALUES (?, ?, ?, ?, ?, ?, ?) " + + "ON CONFLICT(id) DO UPDATE SET " + + "balance = EXCLUDED.balance, status = EXCLUDED.status, " + + "updated_at = EXCLUDED.updated_at"; + + public static final String FIND_BY_ID = + "SELECT * FROM accounts WHERE id = ?"; + + public static final String FIND_BY_HOLDER_USER_ID = + "SELECT DISTINCT a.* FROM accounts a " + + "JOIN account_holders h ON a.id = h.account_id " + + "WHERE h.user_id = ? " + + "ORDER BY a.created_at DESC"; + + public static final String EXISTS = + "SELECT 1 FROM accounts WHERE id = ?"; + + public static final String DELETE = + "DELETE FROM accounts WHERE id = ?"; + + public static final String SAVE_HOLDERS = + "INSERT INTO account_holders (account_id, holder_id, name, role, email) " + + "VALUES (?, ?, ?, ?, ?)"; + + public static final String LOAD_HOLDERS = + "SELECT * FROM account_holders WHERE account_id = ? ORDER BY name"; + + public static final String DELETE_HOLDERS = + "DELETE FROM account_holders WHERE account_id = ?"; +} +``` + +## Testing the Repository + +```java +public class JdbcAccountRepositoryTest { + private JdbcAccountRepository repository; + private DataSource dataSource; + + @Before + public void setup() { + // Use test database + dataSource = createTestDataSource(); + repository = new JdbcAccountRepository(dataSource, new AccountRowMapper()); + } + + @Test + public void shouldSaveAndRetrieveAccount() { + // Arrange + AccountId id = AccountId.random(); + Account account = Account.create( + id, + Money.usd(100_00), + createOwner() + ).orElseThrow(); + + // Act + var saveResult = repository.save(account); + var findResult = repository.findById(id); + + // Assert + assertThat(saveResult).satisfies(r -> { + assertThat(r).isInstanceOf(Success.class); + }); + + assertThat(findResult).satisfies(r -> { + assertThat(r).isInstanceOf(Success.class); + if (r instanceof Success s) { + assertThat(s.value().id()).isEqualTo(id); + assertThat(s.value().balance()).isEqualTo(Money.usd(100_00)); + } + }); + } + + @Test + public void shouldReturnNotFoundForMissingAccount() { + // Act + var result = repository.findById(new AccountId("nonexistent")); + + // Assert + assertThat(result).satisfies(r -> { + assertThat(r).isInstanceOf(Failure.class); + if (r instanceof Failure f) { + assertThat(f.error()) + .isInstanceOf(NotFoundError.class); + } + }); + } + + @Test + public void shouldDeleteAccount() { + // Arrange + Account account = Account.create(...).orElseThrow(); + repository.save(account); + + // Act + var deleteResult = repository.delete(account.id()); + var findResult = repository.findById(account.id()); + + // Assert + assertThat(deleteResult).satisfies(r -> { + assertThat(r).isInstanceOf(Success.class); + }); + + assertThat(findResult).satisfies(r -> { + assertThat(r).isInstanceOf(Failure.class); + }); + } +} +``` + +## Contract Tests + +Use these to ensure all repository implementations follow the contract: + +```java +public abstract class AccountRepositoryContractTest { + protected abstract AccountRepository createRepository(); + + private AccountRepository repository; + + @Before + public void setup() { + repository = createRepository(); + } + + @Test + public void shouldSaveNewAccount() { + // Every repository implementation must support this + Account account = createTestAccount(); + var result = repository.save(account); + + assertThat(result).satisfies(r -> { + assertThat(r).isInstanceOf(Success.class); + }); + } + + @Test + public void shouldFindSavedAccount() { + Account account = createTestAccount(); + repository.save(account); + + var result = repository.findById(account.id()); + assertThat(result).satisfies(r -> { + assertThat(r).isInstanceOf(Success.class); + }); + } + + // Other contract tests... +} + +// Implementation-specific test inherits contract +public class JdbcAccountRepositoryContractTest extends AccountRepositoryContractTest { + @Override + protected AccountRepository createRepository() { + return new JdbcAccountRepository(dataSource, new AccountRowMapper()); + } +} +``` + +## Key Features + +1. **Interface in Domain**: Repository interface lives in domain layer only +2. **Result Returns**: All methods return `Result` for safety +3. **No Exceptions in Domain**: Infrastructure errors wrapped in Result +4. **Implementation Separate**: Repository implementation in infrastructure layer +5. **SQL Isolation**: SQL and row mapping in dedicated classes +6. **Transaction Boundaries**: Handle savepoints and rollback in implementation +7. **Exception Logging**: Log technical errors before transformation +8. **No Leaking Details**: Domain layer never sees SQL or JDBC types +9. **Collection-Like API**: Methods named after domain concepts, not CRUD +10. **Contract Tests**: Use abstract test classes to verify all implementations + +## Best Practices + +- Never expose repository implementation details to domain layer +- Use Result types to maintain clean architecture boundary +- Log infrastructure errors (ERROR level), business errors (WARN level) +- Wrap external exceptions at repository boundary +- Test repositories with contract tests +- Use row mappers for schema-to-domain transformations +- Keep queries in separate constant classes +- Handle transactions explicitly +- Use prepared statements to prevent SQL injection +- Document which aggregate boundaries the repository crosses diff --git a/bin/.claude/skills/ddd-model/languages/java/templates/UseCase.java.md b/bin/.claude/skills/ddd-model/languages/java/templates/UseCase.java.md new file mode 100644 index 0000000..9263d2f --- /dev/null +++ b/bin/.claude/skills/ddd-model/languages/java/templates/UseCase.java.md @@ -0,0 +1,772 @@ +# Use Case Template (Java) + +Template for creating application layer use cases in Java 21+. + +## Pattern + +A use case: +- Is one file, one use case (not a service with multiple methods) +- Has a single `execute()` method +- Uses constructor injection for dependencies +- Returns `Result` for safe error handling +- Orchestrates domain objects and repositories +- Maps domain errors to application errors at boundaries +- Can raise domain events for eventually consistent communication + +## Example 1: WithdrawMoneyUseCase + +```java +package com.example.application.account; + +import com.example.domain.account.*; +import com.example.shared.result.Result; +import static com.example.shared.result.Result.success; +import static com.example.shared.result.Result.failure; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * WithdrawMoneyUseCase - withdraw money from an account. + * + * One use case per file - clear responsibility. + * Coordinates domain and infrastructure: + * 1. Load account from repository + * 2. Execute withdrawal on aggregate + * 3. Persist updated aggregate + * 4. Publish domain events for subscribers + * 5. Return result with response DTO + * + * Errors: + * - Account not found → repository error + * - Insufficient funds → domain error + * - Database failure → infrastructure error + */ +public class WithdrawMoneyUseCase { + private static final Logger logger = LoggerFactory.getLogger(WithdrawMoneyUseCase.class); + + private final AccountRepository accountRepository; + private final DomainEventPublisher eventPublisher; + private final UnitOfWork unitOfWork; + + /** + * Constructor injection - dependencies passed, not created. + * Easier to test and swap implementations. + */ + public WithdrawMoneyUseCase( + AccountRepository accountRepository, + DomainEventPublisher eventPublisher, + UnitOfWork unitOfWork + ) { + this.accountRepository = accountRepository; + this.eventPublisher = eventPublisher; + this.unitOfWork = unitOfWork; + } + + /** + * Execute withdrawal use case. + * + * @param request containing account ID and amount + * @return success with response (new balance), or failure with reason + */ + public Result execute( + WithdrawRequest request + ) { + // Phase 1: Validate request + try { + request.validate(); + } catch (IllegalArgumentException e) { + logger.warn("Invalid withdrawal request: " + e.getMessage()); + return failure(new InvalidRequestError(e.getMessage())); + } + + // Phase 2: Load aggregate + Result accountResult = accountRepository.findById( + request.accountId() + ); + + if (accountResult instanceof Failure f) { + logger.warn("Account not found: " + request.accountId().value()); + return failure(mapRepositoryError(f.error())); + } + + Account account = ((Success) (Object) accountResult).value(); + + // Phase 3: Execute domain logic + Result withdrawResult = account.withdraw( + request.amount() + ); + + if (withdrawResult instanceof Failure f) { + // Map domain error to application error + AccountError domainError = f.error(); + WithdrawError appError = mapDomainError(domainError); + + if (domainError instanceof InsufficientFundsError) { + logger.warn("Withdrawal rejected: " + appError.message()); + } else { + logger.warn("Withdrawal failed: " + appError.message()); + } + + return failure(appError); + } + + // Phase 4: Persist updated aggregate + Result saveResult = unitOfWork.withTransaction(() -> + accountRepository.save(account) + ); + + if (saveResult instanceof Failure f) { + logger.error("Failed to save account after withdrawal: " + f.error().message()); + return failure(mapRepositoryError(f.error())); + } + + // Phase 5: Publish domain events + account.events().forEach(event -> { + logger.info("Publishing event: " + event.getClass().getSimpleName()); + eventPublisher.publish(event); + }); + + // Phase 6: Return response + return success(new WithdrawResponse( + account.id().value(), + account.balance().formatted(), + account.status().toString() + )); + } + + // ================== Error Mapping ================== + + /** + * Map domain errors to application-level errors. + * Each use case can map differently depending on context. + */ + private WithdrawError mapDomainError(AccountError error) { + return switch (error) { + case InsufficientFundsError e -> + new InsufficientFundsError( + e.required().formatted(), + e.available().formatted() + ); + case AccountClosedError e -> + new AccountClosedError(e.id().value()); + case AccountFrozenError e -> + new AccountFrozenError(e.id().value()); + case InvalidAmountError e -> + new InvalidAmountError(e.amount().formatted()); + case InvalidOperationError e -> + new OperationFailedError(e.message()); + default -> + new OperationFailedError("Unexpected error: " + error.message()); + }; + } + + /** + * Map repository/infrastructure errors to application errors. + * Hides infrastructure details from API layer. + */ + private WithdrawError mapRepositoryError(RepositoryError error) { + return switch (error) { + case NotFoundError e -> + new AccountNotFoundError(e.entityId()); + case ConnectionError e -> { + logger.error("Database connection error: " + e.message()); + yield new OperationFailedError("Database unavailable"); + } + case SerializationError e -> { + logger.error("Serialization error: " + e.message()); + yield new OperationFailedError("System error"); + } + default -> { + logger.error("Unexpected repository error: " + error.message()); + yield new OperationFailedError("System error"); + } + }; + } +} +``` + +## Input/Output DTOs + +```java +package com.example.application.account; + +import com.example.domain.account.AccountId; +import com.example.domain.account.Money; +import java.util.Objects; + +/** + * WithdrawRequest - input DTO. + * + * Validation in constructor (compact if record, or method if class). + * Separates API layer concerns from domain. + */ +public record WithdrawRequest( + AccountId accountId, + Money amount +) { + public WithdrawRequest { + if (accountId == null) { + throw new IllegalArgumentException("Account ID required"); + } + if (amount == null) { + throw new IllegalArgumentException("Amount required"); + } + } + + /** + * Explicit validation for complex rules. + */ + public void validate() { + if (amount.isNegativeOrZero()) { + throw new IllegalArgumentException("Amount must be positive"); + } + } +} + +/** + * WithdrawResponse - output DTO. + * + * Contains information for API response. + * Never contains internal IDs or implementation details. + */ +public record WithdrawResponse( + String accountId, + String newBalance, + String accountStatus +) {} +``` + +## Use Case Errors + +```java +package com.example.application.account; + +/** + * WithdrawError - application-layer error for withdrawal. + * + * Sealed interface restricts implementations. + * More specific than domain errors, less specific than domain. + */ +public sealed interface WithdrawError permits + InsufficientFundsError, + AccountClosedError, + AccountFrozenError, + InvalidAmountError, + AccountNotFoundError, + InvalidRequestError, + OperationFailedError { + + String message(); +} + +public record InsufficientFundsError( + String required, + String available +) implements WithdrawError { + @Override + public String message() { + return String.format( + "Insufficient funds: need %s, have %s", + required, available + ); + } +} + +public record AccountClosedError(String accountId) implements WithdrawError { + @Override + public String message() { + return "Account is closed: " + accountId; + } +} + +public record AccountFrozenError(String accountId) implements WithdrawError { + @Override + public String message() { + return "Account is frozen: " + accountId; + } +} + +public record InvalidAmountError(String amount) implements WithdrawError { + @Override + public String message() { + return "Invalid amount: " + amount; + } +} + +public record AccountNotFoundError(String accountId) implements WithdrawError { + @Override + public String message() { + return "Account not found: " + accountId; + } +} + +public record InvalidRequestError(String reason) implements WithdrawError { + @Override + public String message() { + return "Invalid request: " + reason; + } +} + +public record OperationFailedError(String reason) implements WithdrawError { + @Override + public String message() { + return "Operation failed: " + reason; + } +} +``` + +## Example 2: TransferMoneyUseCase + +```java +package com.example.application.transfer; + +import com.example.domain.account.*; +import com.example.domain.transfer.*; +import com.example.shared.result.Result; +import static com.example.shared.result.Result.success; +import static com.example.shared.result.Result.failure; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * TransferMoneyUseCase - transfer money between accounts. + * + * More complex use case coordinating multiple aggregates. + * Handles transaction boundaries and eventual consistency. + * + * Two ways to handle: + * 1. Distributed transaction (2-phase commit) - complex + * 2. Saga pattern - better for async, but more code + * 3. Event sourcing - captures all state changes + * + * This example uses approach 1 (simplified). + */ +public class TransferMoneyUseCase { + private static final Logger logger = LoggerFactory.getLogger(TransferMoneyUseCase.class); + + private final AccountRepository accountRepository; + private final TransferRepository transferRepository; + private final DomainEventPublisher eventPublisher; + private final UnitOfWork unitOfWork; + private final TransferIdGenerator transferIdGenerator; + + public TransferMoneyUseCase( + AccountRepository accountRepository, + TransferRepository transferRepository, + DomainEventPublisher eventPublisher, + UnitOfWork unitOfWork, + TransferIdGenerator transferIdGenerator + ) { + this.accountRepository = accountRepository; + this.transferRepository = transferRepository; + this.eventPublisher = eventPublisher; + this.unitOfWork = unitOfWork; + this.transferIdGenerator = transferIdGenerator; + } + + /** + * Execute money transfer between accounts. + */ + public Result execute( + TransferRequest request + ) { + // Validate request + try { + request.validate(); + } catch (IllegalArgumentException e) { + logger.warn("Invalid transfer request: " + e.getMessage()); + return failure(new InvalidTransferRequestError(e.getMessage())); + } + + // Load source account + Result sourceResult = accountRepository.findById( + request.sourceAccountId() + ); + if (sourceResult instanceof Failure f) { + return failure(new SourceAccountNotFoundError(request.sourceAccountId().value())); + } + Account source = ((Success) (Object) sourceResult).value(); + + // Load destination account + Result destResult = accountRepository.findById( + request.destinationAccountId() + ); + if (destResult instanceof Failure f) { + return failure(new DestinationAccountNotFoundError(request.destinationAccountId().value())); + } + Account destination = ((Success) (Object) destResult).value(); + + // Check accounts are different + if (source.id().equals(destination.id())) { + logger.warn("Transfer attempted to same account"); + return failure(new SameAccountError()); + } + + // Execute transfer within transaction + return unitOfWork.withTransaction(() -> { + // Withdraw from source + Result withdrawResult = source.withdraw(request.amount()); + if (withdrawResult instanceof Failure f) { + AccountError error = f.error(); + if (error instanceof InsufficientFundsError e) { + logger.warn("Transfer rejected: insufficient funds"); + return failure(mapError(e)); + } + logger.warn("Source withdrawal failed: " + error.message()); + return failure(new TransferFailedError(error.message())); + } + + // Deposit to destination + Result depositResult = destination.deposit(request.amount()); + if (depositResult instanceof Failure f) { + logger.error("Destination deposit failed (source already withdrawn!)"); + // This is a problem - source is withdrawn but dest failed + // In production, would need compensating transaction + return failure(new TransferFailedError("Deposit failed after withdrawal")); + } + + // Create transfer aggregate to track it + TransferId transferId = transferIdGenerator.generate(); + Result createResult = Transfer.create( + transferId, + source.id(), + destination.id(), + request.amount() + ); + + if (createResult instanceof Failure f) { + logger.error("Transfer aggregate creation failed"); + return failure(f.error()); + } + + Transfer transfer = ((Success) (Object) createResult).value(); + + // Mark transfer as completed + transfer.complete(); + + // Persist both accounts and transfer + accountRepository.save(source); + accountRepository.save(destination); + transferRepository.save(transfer); + + // Publish all events + source.events().forEach(eventPublisher::publish); + destination.events().forEach(eventPublisher::publish); + transfer.events().forEach(eventPublisher::publish); + + logger.info("Transfer completed: " + transferId.value() + + " from " + source.id().value() + + " to " + destination.id().value() + + " amount " + request.amount().formatted()); + + return success(new TransferResponse( + transfer.id().value(), + transfer.status().toString(), + request.amount().formatted() + )); + }); + } + + // Error mapping helpers... +} + +public record TransferRequest( + AccountId sourceAccountId, + AccountId destinationAccountId, + Money amount +) { + public TransferRequest { + if (sourceAccountId == null || destinationAccountId == null || amount == null) { + throw new IllegalArgumentException("All fields required"); + } + } + + public void validate() { + if (amount.isNegativeOrZero()) { + throw new IllegalArgumentException("Amount must be positive"); + } + } +} + +public record TransferResponse( + String transferId, + String status, + String amount +) {} +``` + +## Use Case with Query (Read Operation) + +```java +package com.example.application.account; + +import com.example.domain.account.*; +import com.example.shared.result.Result; +import static com.example.shared.result.Result.success; +import static com.example.shared.result.Result.failure; + +/** + * GetAccountBalanceUseCase - retrieve account balance (query, no mutation). + * + * Read-only use case - doesn't modify domain state. + * Still returns Result for consistency. + */ +public class GetAccountBalanceUseCase { + private final AccountRepository accountRepository; + + public GetAccountBalanceUseCase(AccountRepository accountRepository) { + this.accountRepository = accountRepository; + } + + /** + * Execute query for account balance. + */ + public Result execute( + GetBalanceRequest request + ) { + Result result = accountRepository.findById( + request.accountId() + ); + + if (result instanceof Failure f) { + return failure(new AccountNotFoundError(request.accountId().value())); + } + + Account account = ((Success) (Object) result).value(); + + return success(new GetBalanceResponse( + account.id().value(), + account.balance().formatted(), + account.status().toString() + )); + } +} + +public record GetBalanceRequest(AccountId accountId) { + public GetBalanceRequest { + if (accountId == null) { + throw new IllegalArgumentException("Account ID required"); + } + } +} + +public record GetBalanceResponse( + String accountId, + String balance, + String status +) {} + +public sealed interface GetBalanceError permits + AccountNotFoundError, + OperationFailedError {} +``` + +## Testing Use Cases + +```java +public class WithdrawMoneyUseCaseTest { + private WithdrawMoneyUseCase useCase; + private AccountRepository accountRepository; + private DomainEventPublisher eventPublisher; + private UnitOfWork unitOfWork; + + @Before + public void setup() { + // Use mock implementations + accountRepository = mock(AccountRepository.class); + eventPublisher = mock(DomainEventPublisher.class); + unitOfWork = mock(UnitOfWork.class); + + useCase = new WithdrawMoneyUseCase( + accountRepository, + eventPublisher, + unitOfWork + ); + } + + @Test + public void shouldWithdrawSuccessfully() { + // Arrange + Account account = createTestAccount(Money.usd(100_00)); + when(accountRepository.findById(any())) + .thenReturn(success(account)); + when(unitOfWork.withTransaction(any())) + .thenAnswer(inv -> { + // Execute the lambda + var lambda = inv.getArgument(0); + return ((java.util.function.Supplier) lambda).get(); + }); + + // Act + var result = useCase.execute(new WithdrawRequest( + account.id(), + Money.usd(50_00) + )); + + // Assert + assertThat(result).satisfies(r -> { + assertThat(r).isInstanceOf(Success.class); + if (r instanceof Success s) { + assertThat(s.value().newBalance()) + .contains("50.00"); // $100 - $50 = $50 + } + }); + + // Verify events published + verify(eventPublisher, times(1)).publish(any()); + } + + @Test + public void shouldRejectInsufficientFunds() { + // Arrange + Account account = createTestAccount(Money.usd(30_00)); + when(accountRepository.findById(any())) + .thenReturn(success(account)); + + // Act + var result = useCase.execute(new WithdrawRequest( + account.id(), + Money.usd(50_00) // More than balance + )); + + // Assert + assertThat(result).satisfies(r -> { + assertThat(r).isInstanceOf(Failure.class); + if (r instanceof Failure f) { + assertThat(f.error()) + .isInstanceOf(InsufficientFundsError.class); + } + }); + + // Verify account not saved + verify(accountRepository, never()).save(any()); + } + + @Test + public void shouldReturnAccountNotFoundError() { + // Arrange + when(accountRepository.findById(any())) + .thenReturn(failure( + new NotFoundError("Account", "nonexistent") + )); + + // Act + var result = useCase.execute(new WithdrawRequest( + new AccountId("nonexistent"), + Money.usd(10_00) + )); + + // Assert + assertThat(result).satisfies(r -> { + assertThat(r).isInstanceOf(Failure.class); + if (r instanceof Failure f) { + assertThat(f.error()) + .isInstanceOf(AccountNotFoundError.class); + } + }); + } +} +``` + +## UnitOfWork Pattern for Transactions + +```java +package com.example.application.shared; + +import com.example.shared.result.Result; + +/** + * UnitOfWork - manages transaction boundaries. + * + * Ensures all changes in a use case are committed atomically. + * Implementation handles JDBC transactions, Spring, Hibernate, etc. + */ +public interface UnitOfWork { + /** + * Execute work within a transaction. + * Commits if no exception, rolls back on exception or Result.failure. + */ + Result withTransaction( + java.util.function.Supplier> work + ); + + /** + * Synchronous version - work runs in transaction. + */ + void withTransactionVoid(Runnable work) throws Exception; +} + +/** + * JDBC implementation of UnitOfWork. + */ +public class JdbcUnitOfWork implements UnitOfWork { + private final DataSource dataSource; + + public JdbcUnitOfWork(DataSource dataSource) { + this.dataSource = dataSource; + } + + @Override + public Result withTransaction( + java.util.function.Supplier> work + ) { + Connection conn = null; + try { + conn = dataSource.getConnection(); + conn.setAutoCommit(false); + + // Execute work + Result result = work.get(); + + if (result instanceof Failure) { + conn.rollback(); + } else { + conn.commit(); + } + + return result; + + } catch (Exception e) { + if (conn != null) { + try { conn.rollback(); } catch (SQLException ex) { + // Log rollback error + } + } + throw new RuntimeException("Transaction failed", e); + } finally { + if (conn != null) { + try { conn.close(); } catch (SQLException e) { + // Log close error + } + } + } + } +} +``` + +## Key Features + +1. **One Use Case Per File**: Single responsibility, easy to find and test +2. **Constructor Injection**: Dependencies passed in, not created +3. **Result Returns**: All methods return `Result` +4. **Error Mapping**: Domain errors → Application errors at boundary +5. **Orchestration**: Coordinates domain objects and repositories +6. **Transaction Boundaries**: Uses UnitOfWork for atomicity +7. **Event Publishing**: Raises domain events for subscribers +8. **Logging Strategy**: WARN for business errors, ERROR for technical +9. **Input Validation**: Validate DTOs before using +10. **No Business Logic in DTOs**: DTOs are data, logic in aggregates + +## Best Practices + +- One execute() method per use case class +- Use constructor injection for testability +- Catch all exceptions at infrastructure boundary +- Log domain errors at WARN level (expected failures) +- Log infrastructure errors at ERROR level (unexpected) +- Return Result types for all operations +- Map errors at layer boundaries +- Use UnitOfWork for transaction coordination +- Publish domain events after persistence succeeds +- Never expose domain objects in responses (use DTOs) +- Keep use cases focused on orchestration, not logic diff --git a/bin/.claude/skills/ddd-model/languages/java/templates/ValueObject.java.md b/bin/.claude/skills/ddd-model/languages/java/templates/ValueObject.java.md new file mode 100644 index 0000000..190c54b --- /dev/null +++ b/bin/.claude/skills/ddd-model/languages/java/templates/ValueObject.java.md @@ -0,0 +1,751 @@ +# Value Object Template (Java) + +Template for creating immutable value objects in Java 21+. + +## Pattern + +A value object is: +- Immutable (cannot change after creation) +- Equality based on all fields (not identity) +- Hashable (can be used in sets/maps) +- Validates data in constructor +- Can use Records (recommended) or Classes +- Can use exceptions-based or Result-based validation + +## Approach 1: Records with Exception-Based Validation + +Simple, compact, perfect for most value objects. + +### Example 1: Money + +```java +package com.example.domain.shared; + +import com.example.shared.result.Result; +import static com.example.shared.result.Result.success; +import static com.example.shared.result.Result.failure; +import java.math.BigDecimal; +import java.math.RoundingMode; + +/** + * Money value object - represents a monetary amount with currency. + * + * Immutable. Validates in compact constructor. + * Uses records for automatic equals/hashCode/toString. + * Can be used in sets and maps. + * + * All operations return new Money instances (immutability). + */ +public record Money( + java.math.BigDecimal amount, + String currency +) { + /** + * Compact constructor - executes BEFORE field assignment. + * Perfect place for validation with exceptions. + */ + public Money { + if (amount == null) { + throw new IllegalArgumentException("Amount cannot be null"); + } + if (currency == null || currency.isBlank()) { + throw new IllegalArgumentException("Currency cannot be blank"); + } + if (currency.length() != 3) { + throw new IllegalArgumentException("Currency must be 3-letter ISO code"); + } + if (amount.signum() < 0) { + throw new IllegalArgumentException("Amount cannot be negative"); + } + // Canonicalize to 2 decimal places + amount = amount.setScale(2, RoundingMode.HALF_UP); + } + + // ================== Factory Methods ================== + + /** + * Create Money in US Dollars. + * + * @param cents amount in cents (1_00 = $1.00) + */ + public static Money usd(long cents) { + return new Money( + BigDecimal.valueOf(cents).setScale(2, RoundingMode.HALF_UP) + .divide(BigDecimal.valueOf(100)), + "USD" + ); + } + + /** + * Create Money in Euros. + */ + public static Money eur(long cents) { + return new Money( + BigDecimal.valueOf(cents).setScale(2, RoundingMode.HALF_UP) + .divide(BigDecimal.valueOf(100)), + "EUR" + ); + } + + /** + * Create zero Money for currency. + */ + public static Money zero(String currency) { + return new Money(BigDecimal.ZERO, currency); + } + + /** + * Create Money with arbitrary amount (throws on error). + * Use for tests only. + */ + public static Money mustOf(String amount, String currency) { + try { + return new Money(new BigDecimal(amount), currency); + } catch (IllegalArgumentException | NumberFormatException e) { + throw new AssertionError("Test money construction failed: " + e.getMessage()); + } + } + + // ================== Operations ================== + + /** + * Add two amounts (must be same currency). + * Returns Result for safe chaining. + */ + public Result add(Money other) { + if (!currency.equals(other.currency)) { + return failure( + new CurrencyMismatchError(currency, other.currency) + ); + } + return success( + new Money(amount.add(other.amount), currency) + ); + } + + /** + * Subtract two amounts (must be same currency). + */ + public Result subtract(Money other) { + if (!currency.equals(other.currency)) { + return failure( + new CurrencyMismatchError(currency, other.currency) + ); + } + return success( + new Money(amount.subtract(other.amount), currency) + ); + } + + /** + * Multiply by factor. + */ + public Money multiply(int factor) { + if (factor < 0) { + throw new IllegalArgumentException("Factor cannot be negative"); + } + return new Money( + amount.multiply(BigDecimal.valueOf(factor)), + currency + ); + } + + /** + * Divide by divisor. + */ + public Money divide(int divisor) { + if (divisor <= 0) { + throw new IllegalArgumentException("Divisor must be positive"); + } + return new Money( + amount.divide(BigDecimal.valueOf(divisor), 2, RoundingMode.HALF_UP), + currency + ); + } + + /** + * Negate amount (flip sign). + */ + public Money negate() { + return new Money(amount.negate(), currency); + } + + // ================== Comparisons ================== + + public boolean isZero() { + return amount.signum() == 0; + } + + public boolean isPositive() { + return amount.signum() > 0; + } + + public boolean isNegative() { + return amount.signum() < 0; + } + + public boolean isNegativeOrZero() { + return amount.signum() <= 0; + } + + public boolean greaterThan(Money other) { + if (!currency.equals(other.currency)) { + throw new IllegalArgumentException("Cannot compare different currencies"); + } + return amount.compareTo(other.amount) > 0; + } + + public boolean lessThan(Money other) { + if (!currency.equals(other.currency)) { + throw new IllegalArgumentException("Cannot compare different currencies"); + } + return amount.compareTo(other.amount) < 0; + } + + public boolean equalsAmount(Money other) { + if (!currency.equals(other.currency)) { + return false; + } + return amount.compareTo(other.amount) == 0; + } + + // ================== Display ================== + + /** + * Format for display (e.g., "USD 123.45"). + */ + public String formatted() { + return String.format("%s %s", currency, amount); + } + + /** + * Record provides toString automatically. + */ +} + +/** + * Money error type - sealed interface for type safety. + */ +public sealed interface MoneyError permits CurrencyMismatchError { + String message(); +} + +public record CurrencyMismatchError( + String from, + String to +) implements MoneyError { + @Override + public String message() { + return String.format("Cannot operate on different currencies: %s vs %s", from, to); + } +} +``` + +### Example 2: EmailAddress + +```java +package com.example.domain.shared; + +/** + * EmailAddress value object. + * + * Validates email format on construction. + * Equality based on email string. + * Immutable and hashable. + */ +public record EmailAddress(String value) { + public EmailAddress { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("Email cannot be empty"); + } + if (!isValidEmail(value)) { + throw new IllegalArgumentException("Invalid email format: " + value); + } + // Normalize to lowercase + value = value.toLowerCase().trim(); + } + + public static EmailAddress of(String email) { + return new EmailAddress(email); + } + + /** + * For tests - panics on invalid format. + */ + public static EmailAddress mustOf(String email) { + try { + return new EmailAddress(email); + } catch (IllegalArgumentException e) { + throw new AssertionError("Test email invalid: " + e.getMessage()); + } + } + + public String domain() { + int at = value.indexOf('@'); + return value.substring(at + 1); + } + + public String localPart() { + int at = value.indexOf('@'); + return value.substring(0, at); + } + + private static boolean isValidEmail(String email) { + return email.matches("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$"); + } + + @Override + public String toString() { + return value; + } +} +``` + +### Example 3: DateRange + +```java +package com.example.domain.shared; + +import java.time.LocalDate; + +/** + * DateRange value object - represents period between two dates. + * + * Validates that start <= end. + * Equality based on both dates. + * Immutable and hashable. + * + * Invariant: startDate must be <= endDate + */ +public record DateRange( + LocalDate startDate, + LocalDate endDate +) { + public DateRange { + if (startDate == null || endDate == null) { + throw new IllegalArgumentException("Dates cannot be null"); + } + if (startDate.isAfter(endDate)) { + throw new IllegalArgumentException( + "Start date must be before end date" + ); + } + } + + public static DateRange of(LocalDate start, LocalDate end) { + return new DateRange(start, end); + } + + public long days() { + return java.time.temporal.ChronoUnit.DAYS.between(startDate, endDate); + } + + public boolean contains(LocalDate date) { + return !date.isBefore(startDate) && !date.isAfter(endDate); + } + + public boolean overlaps(DateRange other) { + return !endDate.isBefore(other.startDate) && + !startDate.isAfter(other.endDate); + } +} +``` + +## Approach 2: Classes with Result-Based Validation + +For complex validation that should return errors instead of throwing. + +### Example: PhoneNumber with Result Validation + +```java +package com.example.domain.shared; + +import com.example.shared.result.Result; +import static com.example.shared.result.Result.success; +import static com.example.shared.result.Result.failure; +import java.util.Objects; + +/** + * PhoneNumber value object - complex validation with Result return. + * + * Immutable class (not record) because we want custom factory + * that returns Result instead of throwing exceptions. + * + * Equality based on country code + number. + */ +public final class PhoneNumber { + private final String countryCode; // +1, +44, +33, etc. + private final String number; // Without country code + + /** + * Private constructor - use factory method. + */ + private PhoneNumber(String countryCode, String number) { + this.countryCode = countryCode; + this.number = number; + } + + /** + * Factory with Result-based validation. + * Returns error instead of throwing for graceful handling. + */ + public static Result of( + String countryCode, + String number + ) { + // Guard: Validate country code + if (countryCode == null || countryCode.isBlank()) { + return failure(new InvalidCountryCodeError("Country code required")); + } + if (!countryCode.startsWith("+")) { + return failure( + new InvalidCountryCodeError("Country code must start with +") + ); + } + + // Guard: Validate number + if (number == null || number.isBlank()) { + return failure(new InvalidPhoneNumberError("Phone number required")); + } + if (!number.matches("^\\d{6,15}$")) { + return failure( + new InvalidPhoneNumberError("Phone must be 6-15 digits") + ); + } + + return success(new PhoneNumber(countryCode, number)); + } + + /** + * Parse full phone (e.g., "+14155552671"). + */ + public static Result parse(String fullPhone) { + if (fullPhone == null || !fullPhone.startsWith("+")) { + return failure(new InvalidPhoneNumberError("Must start with +")); + } + + // Find where digits start + int digitStart = 1; // Skip the + + while (digitStart < fullPhone.length() && + !Character.isDigit(fullPhone.charAt(digitStart))) { + digitStart++; + } + + if (digitStart >= fullPhone.length()) { + return failure(new InvalidPhoneNumberError("No digits found")); + } + + String country = fullPhone.substring(0, digitStart); + String number = fullPhone.substring(digitStart); + + return of(country, number); + } + + /** + * For tests - panics on error. + */ + public static PhoneNumber mustOf(String country, String number) { + return of(country, number) + .orElseThrow(e -> + new AssertionError("Test phone invalid: " + e.message()) + ); + } + + // ================== Getters ================== + + public String countryCode() { + return countryCode; + } + + public String number() { + return number; + } + + public String formatted() { + return countryCode + number; + } + + // ================== Equality ================== + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof PhoneNumber that)) return false; + return Objects.equals(countryCode, that.countryCode) && + Objects.equals(number, that.number); + } + + @Override + public int hashCode() { + return Objects.hash(countryCode, number); + } + + @Override + public String toString() { + return formatted(); + } +} + +/** + * PhoneNumber error type. + */ +public sealed interface PhoneNumberError permits + InvalidCountryCodeError, + InvalidPhoneNumberError { + + String message(); +} + +public record InvalidCountryCodeError(String reason) implements PhoneNumberError { + @Override + public String message() { + return "Invalid country code: " + reason; + } +} + +public record InvalidPhoneNumberError(String reason) implements PhoneNumberError { + @Override + public String message() { + return "Invalid phone number: " + reason; + } +} +``` + +## ID Value Objects + +Common pattern for all entity and aggregate IDs: + +```java +package com.example.domain.shared; + +import java.util.UUID; + +/** + * AccountId value object - unique identifier for accounts. + * + * Using UUID internally but abstracting with value object + * for type safety and potential ID scheme changes. + */ +public record AccountId(String value) { + public AccountId { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("AccountId cannot be blank"); + } + } + + public static AccountId random() { + return new AccountId(UUID.randomUUID().toString()); + } + + public static AccountId of(String id) { + return new AccountId(id); + } + + @Override + public String toString() { + return value; + } +} + +/** + * CustomerId - same pattern as AccountId. + */ +public record CustomerId(String value) { + public CustomerId { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("CustomerId cannot be blank"); + } + } + + public static CustomerId random() { + return new CustomerId(UUID.randomUUID().toString()); + } + + @Override + public String toString() { + return value; + } +} + +/** + * ProductId - same pattern. + */ +public record ProductId(String value) { + public ProductId { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("ProductId cannot be blank"); + } + } + + public static ProductId of(String id) { + return new ProductId(id); + } + + @Override + public String toString() { + return value; + } +} +``` + +## Enumerations as Value Objects + +```java +package com.example.domain.account; + +/** + * AccountStatus - represents possible states. + * Sealed interface with record implementations. + */ +public sealed interface AccountStatus permits + ActiveStatus, + FrozenStatus, + ClosedStatus { + + String displayName(); +} + +public record ActiveStatus() implements AccountStatus { + @Override + public String displayName() { + return "Active"; + } +} + +public record FrozenStatus() implements AccountStatus { + @Override + public String displayName() { + return "Frozen - debit blocked"; + } +} + +public record ClosedStatus() implements AccountStatus { + @Override + public String displayName() { + return "Closed"; + } +} + +// Constants for convenience +public class AccountStatuses { + public static final AccountStatus ACTIVE = new ActiveStatus(); + public static final AccountStatus FROZEN = new FrozenStatus(); + public static final AccountStatus CLOSED = new ClosedStatus(); +} +``` + +## Using Value Objects in Collections + +```java +public class AccountRepository { + private final Set validAmounts = Set.of( + Money.usd(10_00), + Money.usd(50_00), + Money.usd(100_00) + ); + + public boolean isValidAmount(Money amount) { + return validAmounts.contains(amount); // Works because equals based on fields + } + + // Maps keyed by value objects + private final Map emailToCustomer = new HashMap<>(); + + public void registerEmail(EmailAddress email, String customerId) { + emailToCustomer.put(email, customerId); // Works with value objects + } +} +``` + +## Testing Value Objects + +```java +public class MoneyTest { + @Test + void shouldCreateMoney() { + Money money = Money.usd(100_00); // $100.00 + assertThat(money.amount()).isEqualTo(BigDecimal.valueOf(100.00)); + } + + @Test + void shouldRejectNegativeAmount() { + assertThatThrownBy(() -> new Money(BigDecimal.valueOf(-10), "USD")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("cannot be negative"); + } + + @Test + void shouldAddMoney() { + Money m1 = Money.usd(100_00); + Money m2 = Money.usd(50_00); + + var result = m1.add(m2); + assertThat(result).satisfies(r -> { + assertThat(r).isInstanceOf(Success.class); + if (r instanceof Success s) { + assertThat(s.value()).isEqualTo(Money.usd(150_00)); + } + }); + } + + @Test + void shouldRejectAddingDifferentCurrencies() { + Money usd = Money.usd(100_00); + Money eur = Money.eur(100_00); + + var result = usd.add(eur); + assertThat(result).satisfies(r -> { + assertThat(r).isInstanceOf(Failure.class); + if (r instanceof Failure f) { + assertThat(f.error()) + .isInstanceOf(CurrencyMismatchError.class); + } + }); + } + + @Test + void shouldEqualByFieldValue() { + Money m1 = Money.usd(100_00); + Money m2 = Money.usd(100_00); + Money m3 = Money.usd(50_00); + + assertThat(m1).isEqualTo(m2); + assertThat(m1).isNotEqualTo(m3); + } + + @Test + void shouldBeHashable() { + Money m1 = Money.usd(100_00); + Money m2 = Money.usd(100_00); + + Set set = Set.of(m1, m2); // Both go in but set size = 1 + assertThat(set).hasSize(1); + } +} +``` + +## Key Features + +1. **Immutable**: All fields final, no setters +2. **Record-First**: Use records for simple value objects +3. **Validation in Constructor**: Compact constructor or private constructor +4. **Equality by Value**: Override equals/hashCode based on all fields (or records do it) +5. **Hashable**: Can use in sets/maps (records handle this) +6. **Throwable or Result**: Choose exception-based or Result-based validation +7. **Operations Return New**: Never mutate, always return new instance +8. **Factory Methods**: Provide static constructors with validation +9. **Must-Variant**: Provide `mustXxx()` for tests (panics on error) +10. **Type Safe**: Wrap strings/numbers in typed value objects + +## Best Practices + +- Use records unless you need custom field transformations +- Validate in compact constructor or private constructor +- Make records `final` implicitly (they are already) +- Provide static factories with clear names (of, from, parse) +- Use sealed interfaces for enumeration-like value objects +- Make value objects usable in collections (implement hashCode/equals) +- Document invariants that are enforced by validation +- Never have mutable fields +- Consider performance-sensitive scenarios (Money in financial systems) +- Return Result for business rule violations, exceptions for programming errors diff --git a/bin/.claude/skills/ddd-model/rules/clean-arch.md b/bin/.claude/skills/ddd-model/rules/clean-arch.md new file mode 100644 index 0000000..c22ce6c --- /dev/null +++ b/bin/.claude/skills/ddd-model/rules/clean-arch.md @@ -0,0 +1,265 @@ +# Clean Architecture for Go DDD Projects + +## Directory Structure + +``` +project-root/ +├── cmd/ +│ └── api/ +│ └── main.go # Application entry point +├── internal/ +│ ├── domain/ # CORE - No external dependencies +│ │ └── / +│ │ ├── aggregate.go # Aggregate roots +│ │ ├── entity.go # Child entities +│ │ ├── value_objects.go # Value objects +│ │ ├── repository.go # Repository INTERFACES +│ │ ├── service.go # Domain services +│ │ ├── events.go # Domain events +│ │ └── errors.go # Domain-specific errors +│ │ +│ ├── application/ # USE CASES - Depends on Domain +│ │ └── / +│ │ ├── service.go # Application services +│ │ ├── commands.go # Command handlers (CQRS) +│ │ ├── queries.go # Query handlers (CQRS) +│ │ └── dto.go # Data Transfer Objects +│ │ +│ └── infrastructure/ # ADAPTERS - Depends on Domain & App +│ ├── persistence/ +│ │ └── / +│ │ ├── postgres_repository.go +│ │ ├── memory_repository.go +│ │ └── models.go # DB models (separate from domain) +│ ├── messaging/ +│ │ └── kafka_publisher.go +│ └── http/ +│ ├── handlers/ +│ │ └── _handler.go +│ ├── middleware/ +│ └── router.go +├── pkg/ # Shared utilities (if needed) +│ └── errors/ +│ └── errors.go +└── go.mod +``` + +## Layer Responsibilities + +### Domain Layer (`internal/domain/`) + +**Purpose**: Core business logic, independent of all frameworks and infrastructure. + +**Contains**: +- Aggregates, Entities, Value Objects +- Repository interfaces (not implementations!) +- Domain Services +- Domain Events +- Domain Errors + +**Rules**: +- NO imports from `application/` or `infrastructure/` +- NO database packages, HTTP packages, or framework code +- NO struct tags (`json:`, `gorm:`, etc.) +- Pure Go, pure business logic + +**Example**: +```go +package accounts + +// Domain layer - NO external imports + +type Account struct { + id AccountID + balance Money + status Status +} + +// Repository interface - implementation is in infrastructure +type AccountRepository interface { + Save(ctx context.Context, account *Account) error + FindByID(ctx context.Context, id AccountID) (*Account, error) +} +``` + +### Application Layer (`internal/application/`) + +**Purpose**: Orchestrate use cases, coordinate domain objects. + +**Contains**: +- Application Services (Use Cases) +- Command/Query Handlers (CQRS) +- DTOs for input/output +- Transaction management + +**Rules**: +- Imports from `domain/` only +- NO direct database access +- NO HTTP/transport concerns +- Coordinates domain objects, doesn't contain business logic + +**Example**: +```go +package accounts + +import ( + "context" + "myapp/internal/domain/accounts" +) + +type TransferService struct { + accountRepo accounts.AccountRepository + txManager TransactionManager +} + +func (s *TransferService) Transfer(ctx context.Context, cmd TransferCommand) error { + return s.txManager.Execute(ctx, func(ctx context.Context) error { + from, err := s.accountRepo.FindByID(ctx, cmd.FromAccountID) + if err != nil { + return err + } + to, err := s.accountRepo.FindByID(ctx, cmd.ToAccountID) + if err != nil { + return err + } + + // Domain logic in domain objects + if err := from.Withdraw(cmd.Amount); err != nil { + return err + } + if err := to.Deposit(cmd.Amount); err != nil { + return err + } + + // Coordinate persistence + if err := s.accountRepo.Save(ctx, from); err != nil { + return err + } + return s.accountRepo.Save(ctx, to) + }) +} +``` + +### Infrastructure Layer (`internal/infrastructure/`) + +**Purpose**: Implement interfaces defined in domain, connect to external systems. + +**Contains**: +- Repository implementations (Postgres, Redis, etc.) +- Message queue publishers/consumers +- External API clients +- HTTP handlers +- Database models (separate from domain entities) + +**Rules**: +- Implements interfaces from `domain/` +- Can import from `domain/` and `application/` +- Contains all framework-specific code +- Handles mapping between domain and persistence models + +**Example**: +```go +package persistence + +import ( + "context" + "database/sql" + "myapp/internal/domain/accounts" +) + +// Compile-time check +var _ accounts.AccountRepository = (*PostgresAccountRepository)(nil) + +type PostgresAccountRepository struct { + db *sql.DB +} + +func (r *PostgresAccountRepository) Save(ctx context.Context, account *accounts.Account) error { + // Map domain to DB model + model := toDBModel(account) + // Persist + _, err := r.db.ExecContext(ctx, "INSERT INTO accounts ...", ...) + return err +} + +func (r *PostgresAccountRepository) FindByID(ctx context.Context, id accounts.AccountID) (*accounts.Account, error) { + var model accountDBModel + err := r.db.QueryRowContext(ctx, "SELECT ... WHERE id = $1", id.String()).Scan(...) + if err != nil { + return nil, err + } + // Map DB model to domain + return toDomain(model), nil +} +``` + +## Dependency Injection + +Wire dependencies at application startup: + +```go +// cmd/api/main.go +func main() { + // Infrastructure + db := connectDB() + accountRepo := persistence.NewPostgresAccountRepository(db) + + // Application + transferService := application.NewTransferService(accountRepo) + + // HTTP + handler := http.NewAccountHandler(transferService) + + // Start server + router := setupRouter(handler) + http.ListenAndServe(":8080", router) +} +``` + +## Package Naming Conventions + +| Layer | Package Name | Example | +|-------|--------------|---------| +| Domain | Bounded context name | `accounts`, `transfers`, `loyalty` | +| Application | Same as domain | `accounts` (in `application/accounts/`) | +| Infrastructure | Descriptive | `persistence`, `messaging`, `http` | + +## Import Rules + +```go +// ✅ ALLOWED +domain/accounts -> (nothing external) +application/accounts -> domain/accounts +infrastructure/persistence -> domain/accounts, application/accounts + +// ❌ NOT ALLOWED +domain/accounts -> application/accounts // Domain can't know application +domain/accounts -> infrastructure/ // Domain can't know infra +application/accounts -> infrastructure/ // App can't know infra +``` + +## Testing Strategy + +| Layer | Test Type | Mocking | +|-------|-----------|---------| +| Domain | Unit tests | No mocks needed | +| Application | Unit tests | Mock repositories | +| Infrastructure | Integration tests | Real DB (testcontainers) | +| E2E | API tests | Full stack | + +```go +// Domain test - no mocks +func TestAccount_Withdraw(t *testing.T) { + account := accounts.NewAccount(id, accounts.MustMoney(100)) + err := account.Withdraw(accounts.MustMoney(50)) + assert.NoError(t, err) + assert.Equal(t, accounts.MustMoney(50), account.Balance()) +} + +// Application test - mock repository +func TestTransferService(t *testing.T) { + repo := &MockAccountRepository{} + service := NewTransferService(repo) + // ... +} +``` diff --git a/bin/.claude/skills/ddd-model/rules/ddd-rules.md b/bin/.claude/skills/ddd-model/rules/ddd-rules.md new file mode 100644 index 0000000..2a1e61f --- /dev/null +++ b/bin/.claude/skills/ddd-model/rules/ddd-rules.md @@ -0,0 +1,184 @@ +# DDD Rules Checklist + +Use this checklist to validate your domain model against DDD principles. + +## Aggregate Rules + +### Boundaries +- [ ] Aggregate has a clearly defined boundary +- [ ] Aggregate Root is identified and documented +- [ ] Only Aggregate Root is accessible from outside +- [ ] Child entities are encapsulated (not exported or accessed directly) +- [ ] Aggregate is the smallest consistent unit + +### Invariants +- [ ] All invariants are documented with `// Invariant:` comments +- [ ] Constructor validates all invariants +- [ ] Every mutation method maintains invariants +- [ ] Invalid state is impossible to create +- [ ] Aggregate rejects invalid operations (returns error) + +### References +- [ ] No direct object references to other Aggregates +- [ ] References to other Aggregates use ID only +- [ ] ID references are typed (not raw strings/ints) +- [ ] Cross-aggregate consistency is eventual (not immediate) + +### Transactions +- [ ] One Aggregate = one transaction boundary +- [ ] No multi-aggregate transactions in domain layer +- [ ] Application layer coordinates multiple aggregates if needed + +### Sizing +- [ ] Aggregate is not too large (performance issues) +- [ ] Aggregate is not too small (consistency issues) +- [ ] "Just right" - protects business invariants, nothing more + +--- + +## Entity Rules + +### Identity +- [ ] Has a unique identifier +- [ ] ID is assigned at creation time +- [ ] ID is immutable after creation +- [ ] ID type is specific (e.g., `AccountID`, not `string`) + +### Equality +- [ ] Equals compares by ID only +- [ ] Two entities with same ID are considered equal +- [ ] Attribute changes don't affect equality + +### Mutability +- [ ] State changes through explicit methods +- [ ] No public setters +- [ ] Methods express domain operations (not CRUD) +- [ ] Methods return errors for invalid operations + +--- + +## Value Object Rules + +### Immutability +- [ ] All fields are private/unexported +- [ ] No setter methods +- [ ] No methods that modify internal state +- [ ] "Modification" creates new instance + +### Equality +- [ ] Equals compares ALL fields +- [ ] Two VOs with same values are interchangeable +- [ ] No identity concept + +### Validation +- [ ] Constructor validates input +- [ ] Invalid VOs cannot be created +- [ ] Returns error for invalid input +- [ ] Provides `MustXxx()` variant for tests (panics on error) + +### Self-Containment +- [ ] VO contains all related logic +- [ ] Domain logic lives in VO methods +- [ ] Example: `Money.Add(other Money) Money` + +--- + +## Repository Rules + +### Interface Location +- [ ] Repository interface defined in domain layer +- [ ] Implementation in infrastructure layer +- [ ] Domain doesn't know about infrastructure + +### Methods +- [ ] Methods operate on Aggregates (not entities) +- [ ] `Save(aggregate)` - persists aggregate +- [ ] `FindByID(id)` - retrieves aggregate +- [ ] No methods that bypass Aggregate Root + +### Transactions +- [ ] Repository doesn't manage transactions +- [ ] Application layer manages transaction scope +- [ ] One repository call = one aggregate + +--- + +## Domain Service Rules + +### When to Use +- [ ] Operation spans multiple aggregates +- [ ] Operation doesn't naturally belong to any entity/VO +- [ ] Significant domain logic that's not entity behavior + +### Implementation +- [ ] Stateless (no instance variables) +- [ ] Named after domain operation (e.g., `TransferService`) +- [ ] Injected dependencies via constructor +- [ ] Returns domain objects, not DTOs + +--- + +## Domain Event Rules + +### Naming +- [ ] Past tense (e.g., `AccountCreated`, `TransferCompleted`) +- [ ] Describes what happened, not what to do +- [ ] Domain language, not technical terms + +### Content +- [ ] Contains all data needed by handlers +- [ ] Immutable after creation +- [ ] Includes aggregate ID and timestamp + +### Publishing +- [ ] Events raised by Aggregates +- [ ] Published after aggregate is saved +- [ ] Handlers in application or infrastructure layer + +--- + +## Clean Architecture Rules + +### Dependency Direction +- [ ] Domain layer has no external dependencies +- [ ] Application layer depends only on Domain +- [ ] Infrastructure depends on Domain and Application +- [ ] No circular dependencies + +### Layer Contents +- [ ] Domain: Entities, VOs, Aggregates, Repository interfaces, Domain Services +- [ ] Application: Use Cases, Application Services, DTOs +- [ ] Infrastructure: Repository implementations, External services, Framework code + +### Interface Segregation +- [ ] Small, focused interfaces +- [ ] Defined by consumer (domain layer) +- [ ] Implemented by provider (infrastructure) + +--- + +## Common Anti-Patterns to Avoid + +### Anemic Domain Model +- ❌ Entities with only getters/setters +- ❌ All logic in services +- ✅ Rich domain model with behavior + +### Large Aggregates +- ❌ Loading entire object graph +- ❌ Too many entities in one aggregate +- ✅ Small, focused aggregates + +### Aggregate References +- ❌ `order.Customer` (direct reference) +- ✅ `order.CustomerID` (ID reference) + +### Business Logic Leakage +- ❌ Validation in controllers +- ❌ Business rules in repositories +- ✅ All business logic in domain layer + +### Technical Concepts in Domain +- ❌ `@Entity`, `@Column` annotations +- ❌ JSON serialization tags +- ✅ Pure domain objects, mapping in infrastructure diff --git a/bin/.claude/skills/ddd-model/rules/degraded-state-pattern.md b/bin/.claude/skills/ddd-model/rules/degraded-state-pattern.md new file mode 100644 index 0000000..174050a --- /dev/null +++ b/bin/.claude/skills/ddd-model/rules/degraded-state-pattern.md @@ -0,0 +1,178 @@ +# Degraded State Pattern + +## Problem + +When domain invariants evolve in production systems, old data may violate new business rules: + +```java +// Version 1: Email optional +public class Customer { + private EmailAddress email; // nullable +} + +// Version 2: Email becomes required (new invariant) +public static Result create(...) { + if (email == null) { + return Result.failure(new EmailRequiredError()); // ❌ Old data breaks! + } +} +``` + +**Without a migration strategy, existing production data cannot be loaded.** + +## Solution: Degraded State Pattern + +Allow entities to exist in a "degraded state" where some invariants are temporarily violated. The entity: +1. **Can be loaded** from persistence despite missing new required fields +2. **Is flagged** as degraded with clear indication of missing fields +3. **Blocks certain operations** until brought to valid state +4. **Provides path to recovery** through explicit update operations + +## When to Use + +✅ **Use when**: +- Adding new required fields to existing entities +- Tightening validation rules on production data +- Migrating data that doesn't meet current standards +- Gradual rollout of stricter business rules + +❌ **Don't use when**: +- Creating new entities (always enforce current invariants) +- Data corruption (fix at persistence layer instead) +- Temporary technical failures (use retry/circuit breaker instead) + +## Implementation Pattern + +### 1. Dual Factory Methods + +```java +public class Customer { + private final CustomerId id; + private final String name; + private EmailAddress email; // New required field + private final boolean isDegraded; + + /** + * Creates NEW customer (strict validation). + * Enforces all current invariants. + */ + public static Result create( + CustomerId id, + String name, + EmailAddress email + ) { + // Strict validation + if (email == null) { + return Result.failure(new EmailRequiredError( + "Email is required for new customers" + )); + } + return Result.success(new Customer(id, name, email, false)); + } + + /** + * Reconstructs customer from persistence (lenient). + * Allows loading old data that doesn't meet current invariants. + */ + public static Customer fromPersistence( + CustomerId id, + String name, + EmailAddress email // Can be null for old data + ) { + boolean isDegraded = (email == null); + + if (isDegraded) { + log.warn("Customer loaded in degraded state: id={}, missing=email", id); + } + + return new Customer(id, name, email, isDegraded); + } + + private Customer(CustomerId id, String name, EmailAddress email, boolean isDegraded) { + this.id = id; + this.name = name; + this.email = email; + this.isDegraded = isDegraded; + } +} +``` + +### 2. Operation Gating + +Block operations that require valid state: + +```java +public Result placeOrder(OrderDetails details) { + // Guard: Cannot place order in degraded state + if (isDegraded) { + return Result.failure(new CustomerDegradedError( + "Please complete your profile (add email) before placing orders", + List.of("email") + )); + } + + // Normal business logic + Order order = new Order(details, this.email); + return Result.success(order); +} +``` + +### 3. Recovery Path + +Provide explicit operations to exit degraded state: + +```java +public Result updateEmail(EmailAddress newEmail) { + if (newEmail == null) { + return Result.failure(new EmailRequiredError("Email cannot be null")); + } + + this.email = newEmail; + this.isDegraded = false; // Exit degraded state + + log.info("Customer email updated, exited degraded state: id={}", id); + return Result.success(null); +} + +public boolean isDegraded() { + return isDegraded; +} + +public List getMissingFields() { + List missing = new ArrayList<>(); + if (email == null) { + missing.add("email"); + } + return missing; +} +``` + +## Best Practices + +✅ **Do**: +- Use dual factory methods (`create()` strict, `fromPersistence()` lenient) +- Log when entities load in degraded state +- Provide clear error messages with missing fields +- Allow read operations and deposits (recovery paths) +- Block critical operations until valid +- Provide explicit recovery operations +- Monitor degraded entity count in production + +❌ **Don't**: +- Allow new entities to be created in degraded state +- Silently accept degraded state without logging +- Block all operations (allow recovery paths) +- Forget to provide user-facing recovery UI +- Leave entities degraded indefinitely (migrate!) +- Use degraded state for temporary failures + +## Summary + +The Degraded State Pattern enables: +- ✅ **Zero-downtime schema evolution** +- ✅ **Gradual migration of invariants** +- ✅ **Clear user communication** about incomplete data +- ✅ **Explicit recovery paths** to valid state +- ✅ **Production safety** during schema changes + +Use it when domain rules evolve and existing production data doesn't meet new standards. diff --git a/bin/.claude/skills/ddd-model/rules/error-handling.md b/bin/.claude/skills/ddd-model/rules/error-handling.md new file mode 100644 index 0000000..dcdd9e2 --- /dev/null +++ b/bin/.claude/skills/ddd-model/rules/error-handling.md @@ -0,0 +1,47 @@ +# Error Handling in DDD + +This document defines error handling principles that apply across all layers and languages. + +## Core Principles + +1. **No Silent Failures** - All errors must be explicitly handled or propagated +2. **Layer-Specific Errors** - Each layer defines its own error types +3. **Error Transformation** - Errors are transformed at layer boundaries +4. **Proper Logging** - Original errors logged before transformation +5. **Result Types Over Exceptions** - Prefer Result types (where supported) over exceptions + +## Language-Specific Implementation + +- **Java**: See [languages/java/error-handling.md](../languages/java/error-handling.md) +- **Go**: Use error returns and custom error types + +## Error Hierarchy + +``` +Domain Errors (business rule violations) + ↓ transformed at boundary +Application Errors (use case failures) + ↓ transformed at boundary +Infrastructure Errors (technical failures) +``` + +## Logging Strategy + +- **ERROR**: Infrastructure failures (database down, network error) +- **WARN**: Business rule violations (insufficient funds, invalid state) +- **INFO**: Normal operations (order placed, account created) +- **DEBUG**: Detailed flow information +- **TRACE**: Original errors when transforming between layers + +## Exception Boundary + +**Infrastructure Layer** is the exception boundary: +- Infrastructure catches external exceptions (SQL, HTTP, etc.) +- Transforms to domain error types +- Logs original exception at ERROR level +- Returns domain error type + +**Domain and Application** layers: +- Never throw exceptions (use Result types or errors) +- Only work with domain/application error types +- No try/catch blocks needed diff --git a/bin/.claude/skills/ddd-model/rules/invariants.md b/bin/.claude/skills/ddd-model/rules/invariants.md new file mode 100644 index 0000000..f9b5e0d --- /dev/null +++ b/bin/.claude/skills/ddd-model/rules/invariants.md @@ -0,0 +1,282 @@ +# Guide to Defining Invariants + +Invariants are business rules that must ALWAYS be true for an aggregate. They define the consistency boundaries of your domain. + +## What is an Invariant? + +An invariant is a condition that must hold true at all times for an aggregate to be in a valid state. + +**Characteristics**: +- Must be true before AND after every operation +- Cannot be temporarily violated +- Enforced within the aggregate boundary +- Violation results in error (operation rejected) + +## Identifying Invariants + +### Questions to Ask + +1. **"What would break the business if violated?"** + - Example: Negative account balance for non-credit accounts + +2. **"What rules must ALWAYS hold?"** + - Example: Order total = sum of line items + +3. **"What conditions make an operation invalid?"** + - Example: Cannot ship order that's not paid + +4. **"What relationships must be maintained?"** + - Example: Account must have at least one owner + +5. **"What limits exist?"** + - Example: Maximum 10 items per order + +### Common Invariant Categories + +| Category | Example | +|----------|---------| +| **Range constraints** | Balance >= 0, Quantity > 0 | +| **Required relationships** | Order must have at least one line item | +| **State transitions** | Cannot cancel shipped order | +| **Uniqueness** | No duplicate line items for same product | +| **Consistency** | Sum of parts equals total | +| **Business limits** | Max withdrawal per day | + +## Documenting Invariants + +### Comment Format + +```go +// Invariant: +``` + +Place invariant comments: +1. At the struct definition (all invariants) +2. At methods that enforce specific invariants + +### Example + +```go +// Account represents a bank account aggregate. +// Invariants: +// - Balance cannot be negative for standard accounts +// - Account must have at least one holder with OWNER role +// - Frozen account cannot process debit operations +// - Credit limit cannot exceed maximum allowed for account type +type Account struct { + id AccountID + balance Money + holders []Holder + status Status + accountType AccountType + creditLimit Money +} +``` + +## Enforcing Invariants + +### In Constructor + +Validate all invariants at creation time: + +```go +func NewAccount(id AccountID, holder Holder, accountType AccountType) (*Account, error) { + // Invariant: Account must have at least one holder with OWNER role + if holder.Role() != RoleOwner { + return nil, ErrFirstHolderMustBeOwner + } + + // Invariant: Balance starts at zero (implicitly non-negative) + return &Account{ + id: id, + balance: MustMoney(0), + holders: []Holder{holder}, + status: StatusActive, + accountType: accountType, + creditLimit: MustMoney(0), + }, nil +} +``` + +### In Mutation Methods + +Check invariants before AND after state changes: + +```go +func (a *Account) Withdraw(amount Money) error { + // Invariant: Frozen account cannot process debit operations + if a.status == StatusFrozen { + return ErrAccountFrozen + } + + // Invariant: Balance cannot be negative for standard accounts + newBalance, err := a.balance.Subtract(amount) + if err != nil { + return err + } + + if a.accountType == AccountTypeStandard && newBalance.IsNegative() { + return ErrInsufficientFunds + } + + // Invariant: Credit limit cannot be exceeded + if newBalance.IsNegative() && newBalance.Abs().GreaterThan(a.creditLimit) { + return ErrCreditLimitExceeded + } + + a.balance = newBalance + return nil +} +``` + +### In Remove Operations + +Ensure invariants aren't violated by removal: + +```go +func (a *Account) RemoveHolder(holderID HolderID) error { + // Invariant: Account must have at least one holder with OWNER role + remainingOwners := 0 + for _, h := range a.holders { + if h.ID() != holderID && h.Role() == RoleOwner { + remainingOwners++ + } + } + + if remainingOwners == 0 { + return ErrCannotRemoveLastOwner + } + + // Remove holder + a.holders = removeHolder(a.holders, holderID) + return nil +} +``` + +## Invariant Patterns + +### Pattern 1: Validate-Then-Mutate + +```go +func (a *Aggregate) DoSomething(param Param) error { + // 1. Validate preconditions (invariants) + if err := a.validatePreconditions(param); err != nil { + return err + } + + // 2. Perform mutation + a.applyChange(param) + + // 3. Validate postconditions (should always pass if logic is correct) + // Usually implicit, but can be explicit for complex operations + + return nil +} +``` + +### Pattern 2: State Machine Transitions + +```go +func (o *Order) Ship() error { + // Invariant: Only paid orders can be shipped + if o.status != StatusPaid { + return &InvalidStateTransitionError{ + From: o.status, + To: StatusShipped, + Reason: "order must be paid before shipping", + } + } + + o.status = StatusShipped + o.shippedAt = time.Now() + return nil +} +``` + +### Pattern 3: Collection Invariants + +```go +func (o *Order) AddLineItem(item LineItem) error { + // Invariant: No duplicate products in order + for _, existing := range o.lineItems { + if existing.ProductID() == item.ProductID() { + return ErrDuplicateProduct + } + } + + // Invariant: Maximum 10 items per order + if len(o.lineItems) >= 10 { + return ErrMaxItemsExceeded + } + + o.lineItems = append(o.lineItems, item) + + // Invariant: Total must equal sum of line items (maintained automatically) + o.recalculateTotal() + + return nil +} +``` + +## Error Types for Invariant Violations + +Create specific error types for each invariant: + +```go +// Domain errors for invariant violations +var ( + ErrInsufficientFunds = errors.New("insufficient funds") + ErrAccountFrozen = errors.New("account is frozen") + ErrCannotRemoveLastOwner = errors.New("cannot remove last owner") + ErrCreditLimitExceeded = errors.New("credit limit exceeded") +) + +// Or use typed errors with details +type InsufficientFundsError struct { + Requested Money + Available Money +} + +func (e InsufficientFundsError) Error() string { + return fmt.Sprintf("insufficient funds: requested %s, available %s", + e.Requested, e.Available) +} +``` + +## Testing Invariants + +Test both valid and invalid cases: + +```go +func TestAccount_Withdraw_InsufficientFunds(t *testing.T) { + account := NewAccount(id, holder, AccountTypeStandard) + account.Deposit(MustMoney(100)) + + // Invariant violation: Balance cannot be negative + err := account.Withdraw(MustMoney(150)) + + assert.ErrorIs(t, err, ErrInsufficientFunds) + assert.Equal(t, MustMoney(100), account.Balance()) // Unchanged +} + +func TestAccount_RemoveHolder_LastOwner(t *testing.T) { + account := NewAccount(id, owner, AccountTypeStandard) + + // Invariant violation: Cannot remove last owner + err := account.RemoveHolder(owner.ID()) + + assert.ErrorIs(t, err, ErrCannotRemoveLastOwner) + assert.Len(t, account.Holders(), 1) // Unchanged +} +``` + +## Invariants Checklist + +For each aggregate, verify: + +- [ ] All invariants are documented +- [ ] Constructor enforces all creation-time invariants +- [ ] Each mutation method maintains all invariants +- [ ] Specific error types exist for each invariant violation +- [ ] Tests cover both valid operations and invariant violations +- [ ] Invalid state is impossible to reach through any sequence of operations diff --git a/bin/.claude/skills/ddd-model/workflow.md b/bin/.claude/skills/ddd-model/workflow.md new file mode 100644 index 0000000..af8d7a2 --- /dev/null +++ b/bin/.claude/skills/ddd-model/workflow.md @@ -0,0 +1,348 @@ +# DDD Modeling Workflow + +This document provides detailed instructions for each phase of the DDD modeling workflow. + +## Phase 1: Domain Discovery + +### Step 1.1: Gather Domain Information + +Use AskUserQuestion to understand the domain: + +```json +{ + "question": "What domain or subdomain are you modeling?", + "header": "Domain", + "multiSelect": false, + "options": [ + {"label": "New domain", "description": "Starting from scratch with a new business domain"}, + {"label": "Existing domain", "description": "Refactoring or extending an existing domain"}, + {"label": "Subdomain extraction", "description": "Extracting a bounded context from a monolith"} + ] +} +``` + +### Step 1.2: Classify Subdomain Type + +```json +{ + "question": "What type of subdomain is this?", + "header": "Type", + "multiSelect": false, + "options": [ + {"label": "Core (Recommended)", "description": "Competitive advantage, complex business logic, high DDD investment"}, + {"label": "Supporting", "description": "Necessary for Core, moderate complexity, simplified DDD"}, + {"label": "Generic", "description": "Commodity functionality, low complexity, CRUD is fine"} + ] +} +``` + +### Step 1.3: Determine DDD Investment Level + +Based on subdomain type: + +| Subdomain Type | DDD Investment | Patterns to Use | +|----------------|----------------|-----------------| +| Core | Full | Aggregates, Domain Events, Domain Services, CQRS, Event Sourcing (optional) | +| Supporting | Simplified | Aggregates, basic Value Objects, simple Domain Services | +| Generic | Minimal | CRUD, Transaction Script, Active Record | + +### Step 1.4: Identify Business Processes + +Ask about key business processes: + +```json +{ + "question": "What are the main business processes in this domain?", + "header": "Processes", + "multiSelect": true, + "options": [ + {"label": "Create/Register", "description": "Creating new domain entities"}, + {"label": "Update/Modify", "description": "Changing existing entities"}, + {"label": "Workflow/State machine", "description": "Multi-step processes with state transitions"}, + {"label": "Calculations", "description": "Complex business calculations or rules"} + ] +} +``` + +--- + +## Phase 2: Bounded Contexts + +### Step 2.1: List Domain Concepts + +Ask the user to list key concepts: + +"List the main concepts/nouns in your domain. For example: Account, Transaction, Customer, Payment, etc." + +### Step 2.2: Group Concepts into Bounded Contexts + +Look for: +- Concepts that share the same ubiquitous language +- Concepts that change together +- Concepts with the same lifecycle +- Natural boundaries (teams, deployability) + +### Step 2.3: Propose BC Boundaries + +Present a Context Map diagram: + +``` +Example Context Map: + + ┌──────────────────────────────────────────────────────────┐ + │ CORE DOMAIN │ + │ ┌─────────────┐ ┌─────────────┐ │ + │ │ Accounts │─────────>│ Transfers │ │ + │ │ │ Customer │ │ │ + │ │ - Account │ Supplier│ - Transfer │ │ + │ │ - Balance │ │ - Payment │ │ + │ └─────────────┘ └─────────────┘ │ + │ │ │ │ + │ │ Conformist │ Partnership │ + │ v v │ + │ ┌─────────────┐ ┌─────────────┐ │ + │ │ Fees │ │ Loyalty │ │ + │ │ (Supporting)│ │ (Core) │ │ + │ └─────────────┘ └─────────────┘ │ + └──────────────────────────────────────────────────────────┘ +``` + +### Step 2.4: Define Ubiquitous Language + +For each BC, create a glossary: + +```markdown +## Accounts BC - Ubiquitous Language + +| Term | Definition | +|------|------------| +| Account | A financial account owned by a customer | +| Balance | Current amount of money in the account | +| Holder | Person or entity that owns the account | +| Freeze | Temporarily block all operations on account | +``` + +### Step 2.5: Map Context Relationships + +Define relationships between BCs: + +- **Partnership**: Teams cooperate, shared evolution +- **Customer-Supplier**: Upstream provides, downstream consumes +- **Conformist**: Downstream adopts upstream model as-is +- **Anti-corruption Layer**: Downstream translates upstream model +- **Open Host Service**: Upstream provides well-defined protocol +- **Published Language**: Shared language (XML schema, Protobuf) + +--- + +## Phase 3: Tactical Modeling + +### Step 3.1: Identify Entities + +```json +{ + "question": "Which concepts have a unique identity that persists over time?", + "header": "Entities", + "multiSelect": true, + "options": [ + {"label": "User/Customer", "description": "Has ID, identity matters even if attributes change"}, + {"label": "Order/Transaction", "description": "Tracked by ID throughout lifecycle"}, + {"label": "Account/Wallet", "description": "Unique identifier, state changes over time"}, + {"label": "Other (specify)", "description": "I'll describe other entities"} + ] +} +``` + +### Step 3.2: Identify Value Objects + +```json +{ + "question": "Which concepts are defined purely by their values (no identity)?", + "header": "Value Objects", + "multiSelect": true, + "options": [ + {"label": "Money/Amount", "description": "$100 = $100, no unique identity"}, + {"label": "Address/Location", "description": "Same address values = same address"}, + {"label": "DateRange/Period", "description": "Defined by start and end dates"}, + {"label": "Email/Phone", "description": "Value-based, immutable"} + ] +} +``` + +### Step 3.3: Identify Aggregates + +Decision questions: +1. "What entities must always be consistent together?" +2. "What is the smallest unit that must be loaded together?" +3. "What defines a transaction boundary?" + +```json +{ + "question": "Which entity should be the Aggregate Root (entry point)?", + "header": "Aggregate Root", + "multiSelect": false, + "options": [ + {"label": "Account", "description": "Controls Balance, Transactions within it"}, + {"label": "Order", "description": "Controls OrderLines, ShippingInfo"}, + {"label": "Customer", "description": "Controls Addresses, Preferences"}, + {"label": "Other", "description": "Different aggregate root"} + ] +} +``` + +### Step 3.4: Define Aggregate Boundaries + +For each aggregate, determine: +- **Root Entity**: The entry point (only public access) +- **Child Entities**: Internal entities (private, accessed via root) +- **Value Objects**: Immutable data within aggregate +- **Invariants**: Rules that must always hold + +Example: +``` +Account Aggregate +├── Account (Root) +│ ├── AccountID (VO) +│ ├── Balance (VO) +│ ├── Status (VO/Enum) +│ └── Holders[] (Entity) +│ ├── HolderID (VO) +│ └── Role (VO) +└── Invariants: + - Balance >= 0 (for standard accounts) + - At least one holder with OWNER role + - Cannot debit frozen account +``` + +--- + +## Phase 4: Invariants + +### Step 4.1: Gather Business Rules + +```json +{ + "question": "What business rules must ALWAYS be true for this aggregate?", + "header": "Rules", + "multiSelect": true, + "options": [ + {"label": "Non-negative values", "description": "Balance, quantity, amount >= 0"}, + {"label": "Required relationships", "description": "Must have at least one X"}, + {"label": "State constraints", "description": "Cannot do Y when in state Z"}, + {"label": "Consistency rules", "description": "Sum of parts equals total"} + ] +} +``` + +### Step 4.2: Formalize Invariants + +Convert business rules to formal invariants: + +```go +// Invariant: Account balance cannot be negative for standard accounts +// Invariant: Account must have at least one holder with OWNER role +// Invariant: Frozen account cannot process debit operations +// Invariant: Transfer amount must be positive +// Invariant: Source and destination accounts must be different +``` + +### Step 4.3: Map Invariants to Enforcement Points + +| Invariant | Enforcement Point | +|-----------|-------------------| +| Balance >= 0 | `Account.Debit()` method | +| At least one owner | `Account` constructor, `RemoveHolder()` | +| No debit when frozen | `Account.Debit()` method | +| Positive amount | `Money` constructor | + +--- + +## Phase 5: Code Generation + +### Step 5.1: Create Folder Structure + +Use `templates/folder-structure.md` to create: + +``` +internal/ +├── domain/ +│ └── / +│ ├── aggregate.go # Aggregate roots +│ ├── entity.go # Child entities +│ ├── value_objects.go # Value objects +│ ├── repository.go # Repository interfaces +│ ├── errors.go # Domain errors +│ └── events.go # Domain events (optional) +├── application/ +│ └── / +│ ├── service.go # Application services / Use cases +│ └── dto.go # Data transfer objects +└── infrastructure/ + └── / + ├── postgres_repository.go + └── memory_repository.go +``` + +### Step 5.2: Generate Domain Code + +For each aggregate, use templates: +1. `templates/aggregate.go.md` - Generate aggregate root +2. `templates/entity.go.md` - Generate child entities +3. `templates/value-object.go.md` - Generate value objects +4. `templates/repository.go.md` - Generate repository interface + +### Step 5.3: Apply Uber Go Style Guide + +- Use pointer receivers for Aggregates/Entities +- Use value receivers for Value Objects +- Add compile-time interface checks +- Create domain-specific error types +- Use constructor functions with validation + +--- + +## Phase 6: Validation + +### Step 6.1: Run DDD Checklist + +Read `rules/ddd-rules.md` and check each rule: + +```markdown +### Aggregate Validation +- [ ] Aggregate Root is the only public entry point +- [ ] Child entities are not directly accessible +- [ ] All changes go through Aggregate Root methods +- [ ] Invariants checked in constructor +- [ ] Invariants checked in mutation methods +- [ ] No direct references to other Aggregates +- [ ] References to other Aggregates use ID only +``` + +### Step 6.2: Output Validation Report + +Generate a validation report: + +```markdown +## DDD Validation Report + +### ✅ Passed +- Aggregate boundaries are clear +- Value Objects are immutable +- Repository interfaces in domain layer + +### ⚠️ Warnings +- Consider extracting X into separate Value Object +- Y method might be better as Domain Service + +### ❌ Issues +- Direct reference to other Aggregate (should use ID) +- Invariant not enforced in Z method +``` + +### Step 6.3: Suggest Improvements + +Based on validation, suggest: +- Missing Value Objects +- Potential Domain Events +- Domain Services that might be needed +- Possible CQRS opportunities diff --git a/bin/.gitignore b/bin/.gitignore new file mode 100644 index 0000000..4de19cd --- /dev/null +++ b/bin/.gitignore @@ -0,0 +1,65 @@ +# Compiled class files +*.class +target/ +out/ + +# Log files +*.log +logs/ + +# IDE files +.idea/ +*.iml +*.iws +*.ipr +.vscode/ +.classpath +.project +.settings/ + +# OS files +.DS_Store +Thumbs.db + +# Package Files +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar + +# Spring Boot +spring-boot-devtools.properties + +# Environment variables +.env +.env.local + +# Database +*.db +*.sqlite + +# Test coverage +.coverage +htmlcov/ +coverage/ + +# Maven +.mvn/ +mvnw +mvnw.cmd + +# Node.js / Frontend +frontend/node_modules/ +frontend/dist/ +frontend/build/ +frontend/.pnpm-store/ +frontend/**/.turbo/ +frontend/**/.next/ +*.tsbuildinfo +.pnpm-debug.log + +# Git worktrees +.worktrees/ diff --git a/bin/CLAUDE.md b/bin/CLAUDE.md new file mode 100644 index 0000000..f292bfe --- /dev/null +++ b/bin/CLAUDE.md @@ -0,0 +1,73 @@ +# Effigenix ERP – Agent Guide + +## Stack +Java 21, Spring Boot 3.2, PostgreSQL, Liquibase, JWT (JJWT), Maven + +## Architektur +DDD + Clean Architecture. Einweg-Abhängigkeit: `domain → application → infrastructure`. + +``` +de.effigenix. +├── domain.{bc}/ # Reine Geschäftslogik, KEINE Framework-Deps +├── application.{bc}/ # Use Cases, Commands, DTOs +├── infrastructure.{bc}/ # JPA, REST, Security, Audit +└── shared/ # Shared Kernel (Result, AuthorizationPort, Action) +``` + +Bounded Contexts: `usermanagement` (implementiert), `production`, `quality`, `inventory`, `procurement`, `sales`, `labeling`, `filiales` (Platzhalter). + +## Namenskonventionen +| Artefakt | Muster | Beispiel | +|---|---|---| +| Use Case | `{Verb}{Noun}` | `CreateUser`, `AuthenticateUser` | +| Command | `{Verb}{Noun}Command` | `CreateUserCommand` | +| Domain Entity | `{Noun}` | `User`, `Role` | +| Value Object | `{Noun}` | `UserId`, `PasswordHash`, `RoleName` | +| Create-Draft | `{Noun}Draft` | `SupplierDraft` | +| Update-Draft | `{Noun}UpdateDraft` | `SupplierUpdateDraft` | +| Domain Error | `{Noun}Error` (sealed interface) | `UserError.UsernameAlreadyExists` | +| JPA Entity | `{Noun}Entity` | `UserEntity` | +| Mapper | `{Noun}Mapper` | `UserMapper` (Domain↔JPA) | +| Repository (Domain) | `{Noun}Repository` | `UserRepository` (Interface) | +| Repository (Impl) | `Jpa{Noun}Repository` | `JpaUserRepository` | +| Controller | `{Noun}Controller` | `UserController` | +| Web DTO | `{Verb}{Noun}Request` | `CreateUserRequest` | +| Action Enum | `{Noun}Action implements Action` | `ProductionAction` | + +## EntityDraft-Pattern + +Für Aggregate mit komplexer VO-Konstruktion (Address, ContactInfo, PaymentTerms u.ä.) gilt: +Der Application Layer baut **keine** VOs – er erzeugt einen **Draft-Record** mit rohen Strings +und übergibt ihn ans Aggregate. Das Aggregate orchestriert Validierung und VO-Konstruktion intern. + +```java +// Application Layer – nur Daten weitergeben, kein VO-Wissen +var draft = new SupplierDraft(cmd.name(), cmd.phone(), ...); +switch (Supplier.create(draft)) { ... } + +// Domain Layer – validiert intern, gibt Result zurück +public static Result create(SupplierDraft draft) { ... } +public Result update(SupplierUpdateDraft draft) { ... } +``` + +**Regeln:** +- Pflichtfelder: non-null im Draft-Record +- Optionale VOs (z.B. Address, PaymentTerms): `null`-Felder → VO wird nicht konstruiert +- Primitive `int` → `Integer` wenn das Feld optional/nullable sein muss +- Einzelne `updateXxx(VO)`-Methoden entfallen → ersetzt durch ein `update({Noun}UpdateDraft)` +- Uniqueness-Check bleibt im Application Layer (Repository-Concern), nach `Aggregate.create()` +- Invarianten-Kommentar im Aggregat aktuell halten + +## Error Handling +Funktional via `Result` (`shared.common.Result`). Domain-Fehler sind sealed interfaces mit Records. Keine Exceptions im Domain/Application Layer. + +## Commits +Conventional Commits. Kein `Co-Authored-By` Header – niemals. + +## DDD Skill +Für neue Bounded Contexts: `/ddd-implement` Skill verwenden. Dokumentation unter `.claude/skills/ddd-implement/SKILL.md`. + +## Doku +- `docs/QUICK_START.md` – Lokale Entwicklung, Docker, Seed-Daten +- `docs/USER_MANAGEMENT.md` – Referenz-BC mit AuthorizationPort, JWT, Audit +- `TODO.md` – Offene Aufgaben und Waves diff --git a/bin/README.md b/bin/README.md new file mode 100644 index 0000000..b04198c --- /dev/null +++ b/bin/README.md @@ -0,0 +1,115 @@ +# Effigenix Fleischerei ERP + +ERP-System für Fleischereien mit HACCP-Compliance, GoBD-konform, Mehrfilialen-Support. + +## Schnellstart + +### Backend + +```bash +# PostgreSQL starten (Docker) +docker run --name effigenix-postgres \ + -e POSTGRES_DB=effigenix \ + -e POSTGRES_USER=effigenix \ + -e POSTGRES_PASSWORD=effigenix \ + -p 5432:5432 \ + -d postgres:15 + +# Backend bauen & starten +cd backend +mvn spring-boot:run +``` + +> **Kein Docker?** Das Backend startet auch ohne Datenbank im Stub-Modus (Warnlog erscheint). +> Die OpenAPI-Spec ist dann unter http://localhost:8080/api-docs abrufbar. + +### Frontend (Terminal UI) + +```bash +cd frontend +pnpm install +pnpm dev # startet direkt, kein Build-Schritt nötig +``` + +--- + +## Repository Structure + +``` +effigenix/ +├── backend/ # Java Spring Boot Backend +│ ├── src/ # Java source code (DDD + Clean Architecture) +│ ├── docs/ # Backend documentation +│ └── pom.xml +│ +└── frontend/ # TypeScript Frontend (pnpm Monorepo) + ├── apps/cli/ # Terminal UI (Ink/React) + └── packages/ # Shared: api-client, types, validation, config +``` + +## Architektur + +**Domain-Driven Design + Clean Architecture + Java 21 + Spring Boot** + +``` +┌─────────────────────────────────────────────────────┐ +│ Presentation (REST Controllers) │ +└─────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────┐ +│ Application Layer (Use Cases) │ +│ - Transaction Script for Generic Subdomains │ +│ - Rich Domain Model for Core Domains │ +└─────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────┐ +│ Domain Layer (DDD Tactical Patterns) │ +│ - Aggregates, Entities, Value Objects │ +│ - Domain Events, Repositories │ +└─────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────┐ +│ Infrastructure Layer │ +│ - Spring, JPA, PostgreSQL, JWT, REST │ +└─────────────────────────────────────────────────────┘ +``` + +## Bounded Contexts (11) + +### Core Domains +- **Production Management** – Rezeptverwaltung, Chargenproduktion +- **Quality Management** – HACCP-Compliance, Temperaturüberwachung +- **Inventory Management** – Bestandsführung, Lagerverwaltung +- **Procurement** – Einkauf, Wareneingang, Lieferanten +- **Sales** – Auftragserfassung, Rechnungsstellung, Kunden + +### Supporting Domains +- **Labeling** – Etikettendruck mit HACCP-Daten +- **Filiales** – Mehrfilialen-Verwaltung + +### Generic Subdomains +- **User Management** – Authentifizierung, Autorisierung, Rollen *(implementiert)* +- **Reporting** – Standard-Reports +- **Notifications** – E-Mail/SMS-Benachrichtigungen + +## Tech Stack + +| Schicht | Technologie | +|---------|-------------| +| Backend | Java 21, Spring Boot 3.2, Spring Security 6 | +| Datenbank | PostgreSQL 15+, Liquibase | +| Auth | JWT (JJWT), Stateless | +| Build | Maven | +| Frontend | TypeScript, React, Ink (TUI) | +| Packages | pnpm Workspaces, tsup, Zod, Axios | + +## Weiterführend + +- [Backend README](backend/README.md) +- [Frontend README](frontend/README.md) +- [Quick Start (Detail)](backend/docs/QUICK_START.md) +- [User Management](backend/docs/USER_MANAGEMENT.md) + +## License + +Proprietary – Effigenix GmbH diff --git a/bin/TODO.md b/bin/TODO.md new file mode 100644 index 0000000..0f54c0d --- /dev/null +++ b/bin/TODO.md @@ -0,0 +1,13 @@ + + Welle 1 (sofort starten): +1. ✅ User Management BC implementieren +2. ✅ Master Data BC implementieren (Artikel, Lieferanten, Kunden) + + Welle 2 (parallel): + 3. ✅ Inventory BC implementieren (Basis: 8.1-8.3) + 4. ✅ Document Archive BC (Basis: 12.1-12.2) - parallel zu Inventory + +- [x] Liquibase statt Flyway +- [x] Package Struktur gemäß DDD-model skill, ddd-implementer fragen wegen refactor? +- [ ] ActionToPermissionMapper, warum unterschiedliches Vorgehen if/else vs. switch/case +- [ ] Nix Shell für manuelles Testing mit Postgres sowie für Migrationstests