diff --git a/.gitignore b/.gitignore index 4de19cd..a726baa 100644 --- a/.gitignore +++ b/.gitignore @@ -63,3 +63,6 @@ frontend/**/.next/ # Git worktrees .worktrees/ + +# Legacy bin directory +bin/ diff --git a/bin/.claude/skills/ddd-implement/README.md b/bin/.claude/skills/ddd-implement/README.md deleted file mode 100644 index e22a745..0000000 --- a/bin/.claude/skills/ddd-implement/README.md +++ /dev/null @@ -1,188 +0,0 @@ -# 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 deleted file mode 100644 index 736af76..0000000 --- a/bin/.claude/skills/ddd-implement/SKILL.md +++ /dev/null @@ -1,66 +0,0 @@ -# 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 deleted file mode 100644 index 8b11573..0000000 --- a/bin/.claude/skills/ddd-implement/examples/go-example.md +++ /dev/null @@ -1,595 +0,0 @@ -# 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 deleted file mode 100644 index cf6612d..0000000 --- a/bin/.claude/skills/ddd-implement/examples/java-example.md +++ /dev/null @@ -1,637 +0,0 @@ -# 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 deleted file mode 100644 index 8084c52..0000000 --- a/bin/.claude/skills/ddd-implement/system-prompt.md +++ /dev/null @@ -1,787 +0,0 @@ -# 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 deleted file mode 100644 index 157567f..0000000 --- a/bin/.claude/skills/ddd-model/SKILL.md +++ /dev/null @@ -1,172 +0,0 @@ ---- -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 deleted file mode 100644 index e622f0c..0000000 --- a/bin/.claude/skills/ddd-model/examples/banking-go.md +++ /dev/null @@ -1,633 +0,0 @@ -# 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 deleted file mode 100644 index d54ffd9..0000000 --- a/bin/.claude/skills/ddd-model/languages/java/error-handling.md +++ /dev/null @@ -1,781 +0,0 @@ -# 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 deleted file mode 100644 index 5f36a61..0000000 --- a/bin/.claude/skills/ddd-model/languages/java/structure.md +++ /dev/null @@ -1,685 +0,0 @@ -# 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 deleted file mode 100644 index c2f3266..0000000 --- a/bin/.claude/skills/ddd-model/languages/java/style-guide.md +++ /dev/null @@ -1,767 +0,0 @@ -# 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 deleted file mode 100644 index cbfcc37..0000000 --- a/bin/.claude/skills/ddd-model/languages/java/templates/Aggregate.java.md +++ /dev/null @@ -1,687 +0,0 @@ -# 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 deleted file mode 100644 index ef1dddc..0000000 --- a/bin/.claude/skills/ddd-model/languages/java/templates/Entity.java.md +++ /dev/null @@ -1,600 +0,0 @@ -# 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 deleted file mode 100644 index b59b79e..0000000 --- a/bin/.claude/skills/ddd-model/languages/java/templates/Repository.java.md +++ /dev/null @@ -1,721 +0,0 @@ -# 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 deleted file mode 100644 index 9263d2f..0000000 --- a/bin/.claude/skills/ddd-model/languages/java/templates/UseCase.java.md +++ /dev/null @@ -1,772 +0,0 @@ -# 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 deleted file mode 100644 index 190c54b..0000000 --- a/bin/.claude/skills/ddd-model/languages/java/templates/ValueObject.java.md +++ /dev/null @@ -1,751 +0,0 @@ -# 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 deleted file mode 100644 index c22ce6c..0000000 --- a/bin/.claude/skills/ddd-model/rules/clean-arch.md +++ /dev/null @@ -1,265 +0,0 @@ -# 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 deleted file mode 100644 index 2a1e61f..0000000 --- a/bin/.claude/skills/ddd-model/rules/ddd-rules.md +++ /dev/null @@ -1,184 +0,0 @@ -# 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 deleted file mode 100644 index 174050a..0000000 --- a/bin/.claude/skills/ddd-model/rules/degraded-state-pattern.md +++ /dev/null @@ -1,178 +0,0 @@ -# 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 deleted file mode 100644 index dcdd9e2..0000000 --- a/bin/.claude/skills/ddd-model/rules/error-handling.md +++ /dev/null @@ -1,47 +0,0 @@ -# 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 deleted file mode 100644 index f9b5e0d..0000000 --- a/bin/.claude/skills/ddd-model/rules/invariants.md +++ /dev/null @@ -1,282 +0,0 @@ -# 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 deleted file mode 100644 index af8d7a2..0000000 --- a/bin/.claude/skills/ddd-model/workflow.md +++ /dev/null @@ -1,348 +0,0 @@ -# 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 deleted file mode 100644 index 4de19cd..0000000 --- a/bin/.gitignore +++ /dev/null @@ -1,65 +0,0 @@ -# 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 deleted file mode 100644 index f292bfe..0000000 --- a/bin/CLAUDE.md +++ /dev/null @@ -1,73 +0,0 @@ -# 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 deleted file mode 100644 index b04198c..0000000 --- a/bin/README.md +++ /dev/null @@ -1,115 +0,0 @@ -# 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 deleted file mode 100644 index 0f54c0d..0000000 --- a/bin/TODO.md +++ /dev/null @@ -1,13 +0,0 @@ - - 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