Claude-skill-registry java-ddd-hexagonal
git clone https://github.com/majiayu000/claude-skill-registry
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/java-ddd-hexagonal" ~/.claude/skills/majiayu000-claude-skill-registry-java-ddd-hexagonal && rm -rf "$T"
skills/data/java-ddd-hexagonal/SKILL.mdJava DDD Hexagonal Architecture
Guide Claude to create domain modules following Domain-Driven Design principles and hexagonal architecture (Ports & Adapters) for Java + Spring Boot projects.
Overview
Architecture: Hexagonal (Ports & Adapters) with three layers Testing: Kent Beck's TDD workflow - Red → Green → Refactor Adaptable: Reads CLAUDE.md to understand project structure Java Version: Java 17+ with records, sealed classes, pattern matching
Quick Start Workflow
When user requests a new module, follow these steps in order:
Step 0: Read Project Configuration
Read CLAUDE.md at project root to extract:
-
Base package name: Look for package patterns
- Example:
→ base iscom.shop.ecommerce.order.domaincom.shop.ecommerce - Example:
→ base isio.example.app.member.applicationio.example.app
- Example:
-
Module structure: Check directory layout
- Look for:
,modules/{module}/
,libs/buildSrc/
- Look for:
-
Build system: Identify build configuration
- Convention plugins:
plugins { id("conventions") } - Version catalog:
libs.versions.toml
- Convention plugins:
-
Common modules: Find shared libraries
- Domain common:
:libs:common - Adapter libs:
:libs:adapter:*
- Domain common:
-
Tech stack: Extract versions
- Java, Spring Boot versions
- Testing libraries (JUnit 5, Mockito)
If CLAUDE.md not found:
- Ask user for base package
- Use defaults:
com.example.project - Proceed with standard patterns
Step 1: Understand Requirements
Ask clarifying questions:
- Module's purpose? (e.g., "Manage customer orders")
- Main entities? (e.g., "Order, OrderItem, Customer")
- Operations needed? (e.g., "Place order, cancel order, track status")
- Special infrastructure? (Caching, messaging, etc.)
Step 2: Create Module Structure
Use Write and Bash tools directly - NO external scripts needed.
Create directory structure following hexagonal architecture:
modules/{module}/ ├── domain/ │ └── src/ │ ├── main/java/{basePackage}/{module}/domain/ │ │ ├── model/ # Aggregates, entities │ │ ├── vo/ # Value objects (records) │ │ ├── event/ # Domain events (records) │ │ └── exception/ # Domain exceptions │ └── test/java/{basePackage}/{module}/domain/ │ ├── application/ │ └── src/ │ ├── main/java/{basePackage}/{module}/application/ │ │ ├── port/ │ │ │ ├── in/ # Use cases (inbound ports) │ │ │ └── out/ # Repositories (outbound ports) │ │ └── service/ # Use case implementations │ └── test/java/{basePackage}/{module}/application/ │ └── adapter/ ├── in/web/ │ └── src/main/java/{basePackage}/{module}/adapter/in/web/ └── out/persistence/ └── src/main/java/{basePackage}/{module}/adapter/out/persistence/
Example commands:
mkdir -p modules/order/domain/src/main/java/com/example/project/order/domain/model mkdir -p modules/order/domain/src/main/java/com/example/project/order/domain/vo mkdir -p modules/order/application/src/main/java/com/example/project/order/application/port/in # ... continue for all directories
Create
build.gradle.kts files using Write tool:
Domain:
plugins { id("conventions") // Pure Java, NO Spring } dependencies { api(project(":libs:common")) testImplementation(libs.bundles.java.test) }
Application:
plugins { id("springBootConventions") } dependencies { api(project(":modules:order:domain")) implementation(project(":libs:common")) implementation(libs.spring.boot.starter.core) testImplementation(libs.bundles.java.test) }
Adapter:
plugins { id("springBootConventions") } dependencies { implementation(project(":modules:order:domain")) implementation(project(":modules:order:application")) // Add infrastructure dependencies based on needs // e.g., libs.spring.boot.starter.data.jpa }
Update
:settings.gradle.kts
include(":modules:order:domain") include(":modules:order:application") include(":modules:order:adapter:in:web") include(":modules:order:adapter:out:persistence")
Step 3: Follow TDD Workflow
CRITICAL: Always write tests BEFORE implementation.
See: references/tdd-workflow.md for complete guide
Kent Beck's cycle:
- RED: Write failing test
- GREEN: Minimal code to pass
- REFACTOR: Improve structure
- REPEAT: Next test
Development order:
- Domain layer (pure Java, NO Spring)
- Write domain tests first
- Implement value objects (records), entities, aggregates
- Add domain events
- Application layer (Spring allowed)
- Write use case tests with mocked repositories
- Implement services
- Adapter layer (infrastructure)
- Write integration tests
- Implement controllers, repositories
Example TDD session:
// 1. RED: Write failing test class OrderTest { @Test void shouldPlaceOrderWithItems() { var order = Order.place(customerId, items); assertThat(order.getEntityId()).isNotNull(); assertThat(order.getStatus()).isEqualTo(OrderStatus.PENDING); } } // Run → FAILS ✗ // 2. GREEN: Minimal implementation public class Order extends AggregateRoot<OrderId> { public static Order place(CustomerId customerId, List<OrderItem> items) { if (items.isEmpty()) { throw new IllegalArgumentException("Order must contain items"); } var order = new Order(OrderId.generate(), customerId, items, OrderStatus.PENDING); order.registerEvent(new OrderPlacedEvent(order.getEntityId())); return order; } } // Run → PASSES ✓ // 3. REFACTOR: Improve (keep tests green) // 4. REPEAT: Write next test
Step 4: Verify Implementation
Run checks after each refactoring:
./gradlew :modules:order:test ./gradlew check # tests + linting + coverage
Architecture Principles
Hexagonal Architecture (Ports & Adapters)
See: references/hexagonal-architecture.md for complete guide
Three layers:
Domain (pure) ← Application (orchestration) ← Adapter (infrastructure)
Dependency Rule: Always point INWARD
Ports: Interfaces defining contracts
- Inbound ports: Use cases (what app offers)
- Outbound ports: Repositories (what app needs)
Adapters: Concrete implementations
- Inbound adapters: REST, GraphQL, CLI
- Outbound adapters: Databases, APIs, files
Layer Responsibilities
See: references/layer-responsibilities.md for details
Domain:
- Pure business logic
- NO framework dependencies
- NO Spring, NO JPA annotations
Application:
- Use case orchestration
- Port interfaces
- Spring annotations allowed
- Transaction boundaries
Adapter:
- Infrastructure code
- Implement ports
- Any framework/library
- REST, persistence, messaging
DDD Building Blocks
See: references/ddd-principles.md for complete reference
Core concepts:
- Aggregate Root: Transaction boundary, publishes events
- Entity: Identity-based objects
- Value Object: Immutable, validated (use records)
- Domain Event: Something that happened (use records)
- Repository: Persistence abstraction
Common Patterns
Pattern 1: Aggregate Root
public class Order extends AggregateRoot<OrderId> { private final CustomerId customerId; private final List<OrderItem> items; private final OrderStatus status; private Order(OrderId entityId, CustomerId customerId, List<OrderItem> items, OrderStatus status) { super(entityId); this.customerId = customerId; this.items = List.copyOf(items); // Defensive copy this.status = status; } // Factory for new entities (publishes events) public static Order place(CustomerId customerId, List<OrderItem> items) { if (items.isEmpty()) { throw new IllegalArgumentException("Order must contain items"); } var order = new Order( OrderId.generate(), customerId, items, OrderStatus.PENDING ); order.registerEvent(new OrderPlacedEvent(order.getEntityId())); return order; } // Factory for reconstitution (no events) public static Order from(OrderId entityId, CustomerId customerId, List<OrderItem> items, OrderStatus status, LocalDateTime createdAt, LocalDateTime updatedAt) { var order = new Order(entityId, customerId, items, status); order.setCreatedAt(createdAt); order.setUpdatedAt(updatedAt); return order; } // Business methods (immutable updates) public Order cancel() { if (status != OrderStatus.PENDING) { throw new IllegalStateException("Only pending orders can be cancelled"); } var cancelled = new Order(getEntityId(), customerId, items, OrderStatus.CANCELLED); cancelled.setCreatedAt(getCreatedAt()); cancelled.setUpdatedAt(getUpdatedAt()); cancelled.registerEvent(new OrderCancelledEvent(getEntityId())); return cancelled; } // Getters public CustomerId getCustomerId() { return customerId; } public List<OrderItem> getItems() { return items; } public OrderStatus getStatus() { return status; } }
Pattern 2: Value Object (Record)
// ID value object public record OrderId(UUID value) implements ValueObject, Serializable { public static OrderId generate() { return new OrderId(UUID.randomUUID()); } public static OrderId from(String value) { return new OrderId(UUID.fromString(value)); } @Override public String toString() { return value.toString(); } } // Validated value object public record Email(String value) implements ValueObject { private static final Pattern EMAIL_PATTERN = Pattern.compile("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$"); public Email { if (value == null || value.isBlank()) { throw new IllegalArgumentException("Email cannot be blank"); } if (!EMAIL_PATTERN.matcher(value).matches()) { throw new IllegalArgumentException("Invalid email format: " + value); } if (value.length() > 255) { throw new IllegalArgumentException("Email must not exceed 255 characters"); } } } // Composite value object public record OAuthInfo(OAuthProvider provider, String providerId) implements ValueObject { public OAuthInfo { if (providerId == null || providerId.isBlank()) { throw new IllegalArgumentException("Provider ID cannot be blank"); } } }
Pattern 3: Use Case (Port)
public interface PlaceOrderUseCase { Response execute(Command command); record Command( String customerId, List<OrderItemDto> items ) {} record Response(String orderId) {} } public record OrderItemDto( String productId, int quantity, BigDecimal price ) {}
Pattern 4: Service (Implementation)
@Service public class PlaceOrderService implements PlaceOrderUseCase { private final OrderRepository orderRepository; public PlaceOrderService(OrderRepository orderRepository) { this.orderRepository = orderRepository; } @Override @Transactional public Response execute(Command command) { var customerId = CustomerId.from(command.customerId()); var items = command.items().stream() .map(dto -> new OrderItem( ProductId.from(dto.productId()), new Quantity(dto.quantity()), new Money(dto.price()) )) .toList(); var order = Order.place(customerId, items); var saved = orderRepository.save(order); return new Response(saved.getEntityId().toString()); } }
Pattern 5: Repository Port
// Port (Application layer) public interface OrderRepository { Order save(Order order); Optional<Order> findById(OrderId id); void deleteById(OrderId id); } // Adapter (Infrastructure layer) @Repository public class OrderRepositoryAdapter implements OrderRepository { private final OrderJpaRepository persistenceRepo; private final DomainEventPublisher eventPublisher; public OrderRepositoryAdapter(OrderJpaRepository persistenceRepo, DomainEventPublisher eventPublisher) { this.persistenceRepo = persistenceRepo; this.eventPublisher = eventPublisher; } @Override public Order save(Order order) { var entity = OrderMapper.toEntity(order); var saved = persistenceRepo.save(entity); // Publish events order.getEvents().forEach(eventPublisher::publish); return OrderMapper.toDomain(saved); } }
Pattern 6: Controller (Adapter)
@RestController @RequestMapping("/api/orders") public class OrderController { private final PlaceOrderUseCase placeOrderUseCase; public OrderController(PlaceOrderUseCase placeOrderUseCase) { this.placeOrderUseCase = placeOrderUseCase; } @PostMapping public ResponseEntity<OrderResponse> placeOrder(@RequestBody PlaceOrderRequest request) { var response = placeOrderUseCase.execute( new PlaceOrderUseCase.Command( request.customerId(), request.items() ) ); return ResponseEntity.ok(new OrderResponse(response.orderId())); } }
Naming Conventions
Packages:
{basePackage}.{module}.{layer}.{sublayer}
Classes:
- Aggregate:
,OrderCustomer - Value Object:
,OrderId
(records)Email - Event:
(records)OrderPlacedEvent - Use Case:
PlaceOrderUseCase - Service:
PlaceOrderService - Controller:
OrderController - Repository Port:
OrderRepository - Repository Adapter:
OrderRepositoryAdapter
Best Practices
- Test first: Always write failing test before implementation
- One test at a time: Focus on simplest next behavior
- Keep domain pure: NO framework dependencies in domain
- Ports before adapters: Define interfaces before implementations
- Immutable domain: Domain entities return new instances on updates
- Events for side effects: Use domain events for cross-module communication
- Run tests frequently: After every small change
- Commit when green: Never commit failing tests
- Use records for value objects: Immutable by default
- Use sealed classes for enums: Type-safe with pattern matching
Common Issues
Issue: Domain using Spring annotations
Solution: Remove all Spring dependencies from domain/build.gradle.kts
Issue: Circular dependencies
Solution: Use domain events, not direct module dependencies
Issue: Tests failing with Spring context errors
Solution: Domain tests should NOT load Spring context
Issue: Records not validating
Solution: Use compact constructor with validation
Project-Specific Adaptation
This skill adapts to your project:
- Read CLAUDE.md for configuration
- Follow project's build system
- Use project's base package
- Adapt to project's conventions
Core principles remain the same:
- Hexagonal architecture
- DDD building blocks
- TDD workflow
Reference Documentation
- Hexagonal Architecture: Ports & Adapters pattern
- Layer Responsibilities: What each layer should/shouldn't do
- DDD Principles: Building blocks and patterns
- TDD Workflow: Red-Green-Refactor with JUnit 5
Tools
Claude uses these tools directly (no external scripts):
- Write: Create files
- Bash(mkdir): Create directories
- Edit: Modify files
- ./gradlew test: Run tests
- ./gradlew check: Full quality checks