mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 13:59:36 +01:00
chore: remove legacy bin/ directory and add to .gitignore
This commit is contained in:
parent
6c1e6c24bc
commit
4010804af4
26 changed files with 3 additions and 10412 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -63,3 +63,6 @@ frontend/**/.next/
|
||||||
|
|
||||||
# Git worktrees
|
# Git worktrees
|
||||||
.worktrees/
|
.worktrees/
|
||||||
|
|
||||||
|
# Legacy bin directory
|
||||||
|
bin/
|
||||||
|
|
|
||||||
|
|
@ -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<E, T> 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)
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
||||||
<system-prompt src="./system-prompt.md" />
|
|
||||||
|
|
||||||
## 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
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
@ -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<OrderLine> 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<OrderError, Order> 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<OrderLine> 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<OrderError, Void> 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<OrderError, Void> 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<OrderError, Void> 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<OrderError, Void> 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<OrderError, Void> 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<OrderLineError, OrderLine> 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<OrderLineError, Void> 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
|
|
||||||
```
|
|
||||||
|
|
@ -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<E, T>** 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<AccountError, Account> create(Money initialBalance) {
|
|
||||||
if (initialBalance.isNegative()) {
|
|
||||||
return Result.failure(new NegativeBalanceError(initialBalance));
|
|
||||||
}
|
|
||||||
return Result.success(new Account(initialBalance));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mutation returning Result
|
|
||||||
public Result<AccountError, Void> 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<AccountError, Account> create(...) {
|
|
||||||
// Check invariants
|
|
||||||
}
|
|
||||||
|
|
||||||
// Invariant enforced in withdraw
|
|
||||||
public Result<AccountError, Void> 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<HolderError, Void> 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<MoneyError, Money> 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<MoneyError, Money> 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<RepositoryError, Void> save(Account account);
|
|
||||||
Result<RepositoryError, Account> findById(AccountID id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Infrastructure layer: internal/infrastructure/account/persistence/jdbc_repository.java
|
|
||||||
public class JdbcAccountRepository implements AccountRepository {
|
|
||||||
@Override
|
|
||||||
public Result<RepositoryError, Void> 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<E, T>**:
|
|
||||||
|
|
||||||
```java
|
|
||||||
// Domain layer
|
|
||||||
public Result<AccountError, Void> withdraw(Money amount) {
|
|
||||||
// Returns domain errors
|
|
||||||
}
|
|
||||||
|
|
||||||
// Application layer
|
|
||||||
public Result<ApplicationError, AccountDTO> 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<RepositoryError, Account> 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<E,T> / error
|
|
||||||
│ └─ Raise domain events
|
|
||||||
│
|
|
||||||
├─ Entity (child entity)
|
|
||||||
│ ├─ Package-private constructor
|
|
||||||
│ ├─ Static factory (package/private scope)
|
|
||||||
│ ├─ Equality based on ID only
|
|
||||||
│ └─ Methods return Result<E,T> / 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<ApplicationError, DTO>
|
|
||||||
│ ├─ 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<RepositoryError, T> / 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<E, T>
|
|
||||||
- [ ] 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<AccountError, Account> 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<AccountError, Void> 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.**
|
|
||||||
|
|
@ -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/<bc>/` - Domain layer
|
|
||||||
- `internal/application/<bc>/` - Application layer
|
|
||||||
- `internal/infrastructure/<bc>/` - 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`
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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<E, T> 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 <E> Error type (must be sealed interface or final class)
|
|
||||||
* @param <T> Success value type
|
|
||||||
*/
|
|
||||||
public sealed interface Result<E, T> permits Failure, Success {
|
|
||||||
/**
|
|
||||||
* Applies function if result is success.
|
|
||||||
*/
|
|
||||||
<U> Result<E, U> map(java.util.function.Function<T, U> fn);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Chains Result-returning operations.
|
|
||||||
*/
|
|
||||||
<U> Result<E, U> flatMap(java.util.function.Function<T, Result<E, U>> fn);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Applies error function if result is failure.
|
|
||||||
*/
|
|
||||||
Result<E, T> mapError(java.util.function.Function<E, E> fn);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pattern matching on Result.
|
|
||||||
*/
|
|
||||||
<U> U match(
|
|
||||||
java.util.function.Function<E, U> onError,
|
|
||||||
java.util.function.Function<T, U> onSuccess
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns value or throws if error.
|
|
||||||
*/
|
|
||||||
T orElseThrow(java.util.function.Function<E, ? extends RuntimeException> exceptionFn);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns value or default if error.
|
|
||||||
*/
|
|
||||||
T orElse(T defaultValue);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Success result (right side).
|
|
||||||
*/
|
|
||||||
static <E, T> Result<E, T> success(T value) {
|
|
||||||
return new Success<>(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Failure result (left side).
|
|
||||||
*/
|
|
||||||
static <E, T> Result<E, T> failure(E error) {
|
|
||||||
return new Failure<>(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Alias for success().
|
|
||||||
*/
|
|
||||||
static <E, T> Result<E, T> ok(T value) {
|
|
||||||
return success(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Alias for failure().
|
|
||||||
*/
|
|
||||||
static <E, T> Result<E, T> err(E error) {
|
|
||||||
return failure(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Success case - carries the successful value.
|
|
||||||
*/
|
|
||||||
final class Success<E, T> implements Result<E, T> {
|
|
||||||
private final T value;
|
|
||||||
|
|
||||||
Success(T value) {
|
|
||||||
this.value = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public <U> Result<E, U> map(java.util.function.Function<T, U> fn) {
|
|
||||||
return new Success<>(fn.apply(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public <U> Result<E, U> flatMap(java.util.function.Function<T, Result<E, U>> fn) {
|
|
||||||
return fn.apply(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Result<E, T> mapError(java.util.function.Function<E, E> fn) {
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public <U> U match(
|
|
||||||
java.util.function.Function<E, U> onError,
|
|
||||||
java.util.function.Function<T, U> onSuccess
|
|
||||||
) {
|
|
||||||
return onSuccess.apply(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public T orElseThrow(java.util.function.Function<E, ? extends RuntimeException> 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<E, T> implements Result<E, T> {
|
|
||||||
private final E error;
|
|
||||||
|
|
||||||
Failure(E error) {
|
|
||||||
this.error = error;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public <U> Result<E, U> map(java.util.function.Function<T, U> fn) {
|
|
||||||
return new Failure<>(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public <U> Result<E, U> flatMap(java.util.function.Function<T, Result<E, U>> fn) {
|
|
||||||
return new Failure<>(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Result<E, T> mapError(java.util.function.Function<E, E> fn) {
|
|
||||||
return new Failure<>(fn.apply(error));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public <U> U match(
|
|
||||||
java.util.function.Function<E, U> onError,
|
|
||||||
java.util.function.Function<T, U> onSuccess
|
|
||||||
) {
|
|
||||||
return onError.apply(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public T orElseThrow(java.util.function.Function<E, ? extends RuntimeException> 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 <E, T> Result<E, T> ok(T value) {
|
|
||||||
return Result.success(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static <E, T> Result<E, T> fail(E error) {
|
|
||||||
return Result.failure(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Usage with static import
|
|
||||||
import static com.example.shared.result.Results.*;
|
|
||||||
|
|
||||||
Result<AccountError, Account> result = ok(account);
|
|
||||||
Result<AccountError, Void> 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<WithdrawError, Account> 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<TransferError, TransferRecord> 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<?, Void> withdrawn = fromAccount.withdraw(amount);
|
|
||||||
if (withdrawn instanceof Failure failure) {
|
|
||||||
return Result.failure(new WithdrawalFailed(failure.error().message()));
|
|
||||||
}
|
|
||||||
|
|
||||||
Result<?, Void> 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<WithdrawError, Account> 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<WithdrawError, Account> 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<RepositoryError, Account> 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<PaymentError, PaymentConfirmation> 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<OrderError, Void> 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<OrderError, Void> 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<WithdrawError, Account> 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<RepositoryError, Account> 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<WithdrawError, Account> 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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
@ -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
|
|
||||||
<project>
|
|
||||||
<modelVersion>4.0.0</modelVersion>
|
|
||||||
<groupId>com.example</groupId>
|
|
||||||
<artifactId>banking-system</artifactId>
|
|
||||||
<version>1.0.0</version>
|
|
||||||
|
|
||||||
<properties>
|
|
||||||
<maven.compiler.release>21</maven.compiler.release>
|
|
||||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
|
||||||
</properties>
|
|
||||||
|
|
||||||
<dependencies>
|
|
||||||
<!-- Java 21 features (no additional dependencies needed) -->
|
|
||||||
|
|
||||||
<!-- Testing -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>junit</groupId>
|
|
||||||
<artifactId>junit-jupiter</artifactId>
|
|
||||||
<version>5.10.0</version>
|
|
||||||
<scope>test</scope>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<!-- Logging (infrastructure layer only) -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.slf4j</groupId>
|
|
||||||
<artifactId>slf4j-api</artifactId>
|
|
||||||
<version>2.0.7</version>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<!-- Database (infrastructure only) -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.postgresql</groupId>
|
|
||||||
<artifactId>postgresql</artifactId>
|
|
||||||
<version>42.6.0</version>
|
|
||||||
</dependency>
|
|
||||||
</dependencies>
|
|
||||||
|
|
||||||
<build>
|
|
||||||
<plugins>
|
|
||||||
<plugin>
|
|
||||||
<groupId>org.apache.maven.plugins</groupId>
|
|
||||||
<artifactId>maven-compiler-plugin</artifactId>
|
|
||||||
<version>3.11.0</version>
|
|
||||||
<configuration>
|
|
||||||
<release>21</release>
|
|
||||||
</configuration>
|
|
||||||
</plugin>
|
|
||||||
</plugins>
|
|
||||||
</build>
|
|
||||||
</project>
|
|
||||||
```
|
|
||||||
|
|
||||||
## 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<E, T> 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<OpenAccountError, OpenAccountResponse> execute(
|
|
||||||
OpenAccountRequest request
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
// Phase 1: Create aggregate
|
|
||||||
AccountId accountId = idGenerator.generate();
|
|
||||||
Result<AccountError, Account> accountResult = Account.create(
|
|
||||||
accountId,
|
|
||||||
request.initialBalance(),
|
|
||||||
request.accountHolder()
|
|
||||||
);
|
|
||||||
|
|
||||||
if (accountResult instanceof Failure f) {
|
|
||||||
return failure(mapError(f.error()));
|
|
||||||
}
|
|
||||||
|
|
||||||
Account account = ((Success<AccountError, Account>) 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<E, T>
|
|
||||||
├── 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
|
|
||||||
|
|
@ -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<MoneyError, Money> 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<AccountError, Account> 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<AccountError, Void> 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<OrderError, Order> 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<AccountError, Account> account = Result.success(newAccount);
|
|
||||||
|
|
||||||
// ✅ With static imports
|
|
||||||
import static com.example.shared.result.Result.success;
|
|
||||||
import static com.example.shared.result.Result.failure;
|
|
||||||
|
|
||||||
Result<AccountError, Account> account = success(newAccount);
|
|
||||||
Result<AccountError, Void> 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<OrderLine> 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<OrderError, Order> 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<OrderLine> 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<MoneyError, Money> 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<OrderLine> lineItems;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Public factory - creates Order and its children.
|
|
||||||
*/
|
|
||||||
public static Result<OrderError, Order> create(...) {
|
|
||||||
return Result.success(new Order(...));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Package-private - only Order and other aggregate classes access this.
|
|
||||||
*/
|
|
||||||
public Result<OrderError, Void> 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<OrderLine> lineItems;
|
|
||||||
|
|
||||||
public List<OrderLine> 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<OrderLine> 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<OrderError, Order> 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<OrderError, Void> 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<E, T>** instead of throwing exceptions
|
|
||||||
|
|
@ -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<Error, Aggregate>`
|
|
||||||
- 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<AccountHolder> holders;
|
|
||||||
private final Instant createdAt;
|
|
||||||
private Instant updatedAt;
|
|
||||||
private final List<DomainEvent> 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<AccountHolder> 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<AccountError, Account> 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<AccountError, Account> 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<AccountError, Account>) (Object) standardAccount;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Construct credit account from standard
|
|
||||||
Account account = ((Success<AccountError, Account>) (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<AccountHolder> 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<AccountError, Void> 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<AccountError, Void> 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<AccountError, Void> 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<AccountError, Void> 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<AccountError, Void> 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<AccountError, Void> 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<AccountError, Void> 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<AccountHolder> 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<DomainEvent> events() {
|
|
||||||
List<DomainEvent> 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<Error, Aggregate>` 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<AccountError, Account> 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<AccountError, Account> 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<AccountError, Void> 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()`
|
|
||||||
|
|
@ -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<OrderLine> 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<OrderError, Void> 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<OrderError, Void> 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<OrderError, Void> 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<OrderLine> 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
|
|
||||||
|
|
@ -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<RepositoryError, T> 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<RepositoryError, Void> save(Account account);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieve account by ID.
|
|
||||||
*
|
|
||||||
* @param id the account identifier
|
|
||||||
* @return success with account if found, failure if not found
|
|
||||||
*/
|
|
||||||
Result<RepositoryError, Account> 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<RepositoryError, List<Account>> findByHolderUserId(UserId userId);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if account exists.
|
|
||||||
*
|
|
||||||
* @param id the account identifier
|
|
||||||
* @return success with boolean, failure if query fails
|
|
||||||
*/
|
|
||||||
Result<RepositoryError, Boolean> exists(AccountId id);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete account (soft or hard).
|
|
||||||
*
|
|
||||||
* @param id the account to delete
|
|
||||||
* @return success if deleted, failure otherwise
|
|
||||||
*/
|
|
||||||
Result<RepositoryError, Void> 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<RepositoryError, Void> 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<RepositoryError, Account> 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<AccountHolder> 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<RepositoryError, List<Account>> 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<Account> accounts = new ArrayList<>();
|
|
||||||
while (rs.next()) {
|
|
||||||
Account account = rowMapper.mapRow(rs);
|
|
||||||
|
|
||||||
// Load holders
|
|
||||||
List<AccountHolder> 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<RepositoryError, Boolean> 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<RepositoryError, Void> 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<AccountHolder> 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<AccountHolder> loadHolders(Connection conn, AccountId accountId)
|
|
||||||
throws SQLException {
|
|
||||||
String sql = "SELECT * FROM account_holders WHERE account_id = ? ORDER BY name";
|
|
||||||
List<AccountHolder> 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<RepositoryError, Account> 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<RepositoryError, Account> 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<Error, T>` 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
|
|
||||||
|
|
@ -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<Error, Response>` 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<WithdrawError, WithdrawResponse> 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<RepositoryError, Account> 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<RepositoryError, Account>) (Object) accountResult).value();
|
|
||||||
|
|
||||||
// Phase 3: Execute domain logic
|
|
||||||
Result<AccountError, Void> 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<RepositoryError, Void> 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<TransferError, TransferResponse> 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<RepositoryError, Account> 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<RepositoryError, Account> 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<AccountError, Void> 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<AccountError, Void> 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<TransferError, Transfer> 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<GetBalanceError, GetBalanceResponse> execute(
|
|
||||||
GetBalanceRequest request
|
|
||||||
) {
|
|
||||||
Result<RepositoryError, Account> result = accountRepository.findById(
|
|
||||||
request.accountId()
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result instanceof Failure f) {
|
|
||||||
return failure(new AccountNotFoundError(request.accountId().value()));
|
|
||||||
}
|
|
||||||
|
|
||||||
Account account = ((Success<RepositoryError, Account>) (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<WithdrawError, WithdrawResponse> 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<WithdrawError, WithdrawResponse> 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<WithdrawError, WithdrawResponse> 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.
|
|
||||||
*/
|
|
||||||
<E, T> Result<E, T> withTransaction(
|
|
||||||
java.util.function.Supplier<Result<E, T>> 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 <E, T> Result<E, T> withTransaction(
|
|
||||||
java.util.function.Supplier<Result<E, T>> work
|
|
||||||
) {
|
|
||||||
Connection conn = null;
|
|
||||||
try {
|
|
||||||
conn = dataSource.getConnection();
|
|
||||||
conn.setAutoCommit(false);
|
|
||||||
|
|
||||||
// Execute work
|
|
||||||
Result<E, T> 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<Error, Response>`
|
|
||||||
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
|
|
||||||
|
|
@ -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<MoneyError, Money> 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<MoneyError, Money> 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<PhoneNumberError, PhoneNumber> 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<PhoneNumberError, PhoneNumber> 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<Money> 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<EmailAddress, String> 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<MoneyError, Money> 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<MoneyError, Money> 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<Money> 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
|
|
||||||
|
|
@ -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
|
|
||||||
│ │ └── <bounded-context>/
|
|
||||||
│ │ ├── 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
|
|
||||||
│ │ └── <bounded-context>/
|
|
||||||
│ │ ├── 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/
|
|
||||||
│ │ └── <bounded-context>/
|
|
||||||
│ │ ├── postgres_repository.go
|
|
||||||
│ │ ├── memory_repository.go
|
|
||||||
│ │ └── models.go # DB models (separate from domain)
|
|
||||||
│ ├── messaging/
|
|
||||||
│ │ └── kafka_publisher.go
|
|
||||||
│ └── http/
|
|
||||||
│ ├── handlers/
|
|
||||||
│ │ └── <bounded-context>_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)
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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<CustomerError, Customer> 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<CustomerError, Customer> 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<CustomerError, Order> 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<CustomerError, Void> 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<String> getMissingFields() {
|
|
||||||
List<String> 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.
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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: <clear, concise description>
|
|
||||||
```
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
@ -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/
|
|
||||||
│ └── <bc>/
|
|
||||||
│ ├── 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/
|
|
||||||
│ └── <bc>/
|
|
||||||
│ ├── service.go # Application services / Use cases
|
|
||||||
│ └── dto.go # Data transfer objects
|
|
||||||
└── infrastructure/
|
|
||||||
└── <bc>/
|
|
||||||
├── 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
|
|
||||||
65
bin/.gitignore
vendored
65
bin/.gitignore
vendored
|
|
@ -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/
|
|
||||||
|
|
@ -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<E,T>, 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<SupplierError, Supplier> create(SupplierDraft draft) { ... }
|
|
||||||
public Result<SupplierError, Void> 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<E, T>` (`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
|
|
||||||
115
bin/README.md
115
bin/README.md
|
|
@ -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
|
|
||||||
13
bin/TODO.md
13
bin/TODO.md
|
|
@ -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
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue