Skip to content

Commit 653a41b

Browse files
authored
Merge pull request #5 from ducpm2303/feat/skill-java-clean-arch
feat(java-core): add java-clean-arch skill
2 parents cfe92c0 + 7f467d2 commit 653a41b

2 files changed

Lines changed: 388 additions & 0 deletions

File tree

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
---
2+
description: Reviews or implements Clean Architecture / Hexagonal Architecture (Ports & Adapters) and DDD tactical patterns for Java projects. Use when user asks to "apply clean architecture", "implement hexagonal architecture", "add ports and adapters", "apply DDD", "refactor to clean arch", "review architecture", or "add value objects".
3+
argument-hint: "[review | implement | ddd] [module or package name]"
4+
allowed-tools: Read, Grep, Glob
5+
---
6+
7+
# /java-clean-arch — Clean / Hexagonal Architecture Advisor
8+
9+
You are a Java architecture specialist. Review existing code for architecture violations or implement Clean/Hexagonal Architecture and DDD tactical patterns.
10+
11+
## Step 1 — Understand the current structure
12+
13+
Scan `src/main/java/` and map the existing package layout. Identify the architecture style:
14+
15+
| Pattern | Signs |
16+
|---|---|
17+
| **Layered** | `controller/`, `service/`, `repository/`, `entity/` |
18+
| **Package-by-feature** | `order/`, `user/`, `product/` with sub-packages |
19+
| **Hexagonal** | `domain/`, `application/`, `infrastructure/`, `adapter/` |
20+
| **Mixed / unclear** | None of the above clearly |
21+
22+
Then determine mode from argument: `review` (default), `implement`, or `ddd`.
23+
24+
---
25+
26+
## Step 2 — Review mode: audit for violations
27+
28+
**Dependency rule violations** (inner layers must not know outer layers):
29+
30+
| Violation | Example | Severity |
31+
|---|---|---|
32+
| Domain imports Spring annotations | `@Entity`, `@Service` in domain classes | HIGH |
33+
| Domain imports infrastructure types | `JpaRepository`, `HttpServletRequest` in domain | HIGH |
34+
| Use case / service imports controller types | `ResponseEntity` in service layer | HIGH |
35+
| Repository interface in domain returns JPA entity | Domain leaks persistence model | MEDIUM |
36+
| Business logic in controller | `if/else` rules in `@RestController` | MEDIUM |
37+
| Business logic in JPA entity | Complex calculations in `@Entity` | MEDIUM |
38+
39+
**DDD tactical pattern opportunities:**
40+
41+
- Plain `String` for IDs → suggest `ProductId` value object
42+
- Primitive obsession (e.g., `String email`, `String phone`) → suggest value objects with validation
43+
- Anemic domain model (entities are just getters/setters, all logic in services) → suggest moving behaviour to domain
44+
- Missing domain events for side effects → suggest `ProductCreatedEvent`, etc.
45+
46+
Report each finding with file:line, violation type, and a concrete refactoring suggestion.
47+
48+
---
49+
50+
## Step 3 — Implement mode: scaffold hexagonal structure
51+
52+
Generate the target package layout and explain the role of each layer:
53+
54+
```
55+
src/main/java/{base-package}/
56+
├── domain/ ← innermost, no dependencies
57+
│ ├── model/ ← entities, value objects, aggregates
58+
│ │ ├── Product.java ← rich domain entity
59+
│ │ ├── ProductId.java ← value object
60+
│ │ └── Money.java ← value object
61+
│ ├── port/
62+
│ │ ├── in/ ← use case interfaces (driving ports)
63+
│ │ │ ├── CreateProductUseCase.java
64+
│ │ │ └── GetProductUseCase.java
65+
│ │ └── out/ ← repository/external interfaces (driven ports)
66+
│ │ └── ProductRepository.java
67+
│ └── event/ ← domain events
68+
│ └── ProductCreatedEvent.java
69+
70+
├── application/ ← orchestrates domain, no framework code
71+
│ └── service/
72+
│ └── ProductService.java ← implements use case interfaces
73+
74+
└── infrastructure/ ← outermost, all framework/DB/HTTP code
75+
├── adapter/
76+
│ ├── in/
77+
│ │ └── web/
78+
│ │ └── ProductController.java ← REST adapter
79+
│ └── out/
80+
│ └── persistence/
81+
│ ├── ProductJpaEntity.java ← JPA model (separate from domain entity)
82+
│ ├── ProductJpaRepository.java
83+
│ └── ProductPersistenceAdapter.java ← implements domain port
84+
└── config/
85+
└── BeanConfig.java
86+
```
87+
88+
Use the templates in `references/patterns.md` for each layer.
89+
90+
---
91+
92+
## Step 4 — DDD mode: implement tactical patterns
93+
94+
### Value Objects (Java 16+: use records)
95+
96+
```java
97+
// Java 16+
98+
public record ProductId(Long value) {
99+
public ProductId {
100+
Objects.requireNonNull(value, "ProductId cannot be null");
101+
if (value <= 0) throw new IllegalArgumentException("ProductId must be positive");
102+
}
103+
}
104+
105+
public record Money(BigDecimal amount, String currency) {
106+
public static final String DEFAULT_CURRENCY = "USD";
107+
public Money {
108+
Objects.requireNonNull(amount);
109+
if (amount.compareTo(BigDecimal.ZERO) < 0)
110+
throw new IllegalArgumentException("Money cannot be negative");
111+
}
112+
public Money add(Money other) {
113+
if (!this.currency.equals(other.currency))
114+
throw new IllegalArgumentException("Currency mismatch");
115+
return new Money(this.amount.add(other.amount), this.currency);
116+
}
117+
}
118+
```
119+
120+
### Rich Domain Entity
121+
122+
```java
123+
public class Product { // NO @Entity here — pure domain
124+
private final ProductId id;
125+
private String name;
126+
private Money price;
127+
private boolean active;
128+
private final List<DomainEvent> domainEvents = new ArrayList<>();
129+
130+
public static Product create(String name, Money price) {
131+
Product p = new Product(ProductId.generate(), name, price, true);
132+
p.domainEvents.add(new ProductCreatedEvent(p.id, p.name));
133+
return p;
134+
}
135+
136+
public void deactivate() {
137+
if (!this.active) throw new IllegalStateException("Already inactive");
138+
this.active = false;
139+
domainEvents.add(new ProductDeactivatedEvent(this.id));
140+
}
141+
142+
public List<DomainEvent> pullDomainEvents() {
143+
var events = List.copyOf(domainEvents);
144+
domainEvents.clear();
145+
return events;
146+
}
147+
// ... getters only, no setters
148+
}
149+
```
150+
151+
### Domain Port (interface in domain layer)
152+
153+
```java
154+
// in domain/port/out/
155+
public interface ProductRepository {
156+
Optional<Product> findById(ProductId id);
157+
Product save(Product product);
158+
List<Product> findAll();
159+
void delete(ProductId id);
160+
}
161+
```
162+
163+
### Persistence Adapter (in infrastructure)
164+
165+
```java
166+
// Implements domain port, lives in infrastructure
167+
@Component
168+
@RequiredArgsConstructor
169+
public class ProductPersistenceAdapter implements ProductRepository {
170+
171+
private final ProductJpaRepository jpaRepository;
172+
173+
@Override
174+
public Optional<Product> findById(ProductId id) {
175+
return jpaRepository.findById(id.value()).map(this::toDomain);
176+
}
177+
178+
@Override
179+
public Product save(Product product) {
180+
ProductJpaEntity entity = toJpa(product);
181+
return toDomain(jpaRepository.save(entity));
182+
}
183+
184+
private Product toDomain(ProductJpaEntity e) { ... } // mapping logic
185+
private ProductJpaEntity toJpa(Product p) { ... }
186+
}
187+
```
188+
189+
---
190+
191+
## Step 5 — Migration path (layered → hexagonal)
192+
193+
If the project is currently layered, suggest a phased migration:
194+
195+
1. **Phase 1** — Extract domain interfaces (ports): create `ProductRepository` interface in domain; keep Spring Data impl in infrastructure
196+
2. **Phase 2** — Decouple domain entity from JPA: create separate `ProductJpaEntity`, map in adapter
197+
3. **Phase 3** — Move business logic from service into domain entity; service becomes thin orchestrator
198+
4. **Phase 4** — Add value objects for primitive types
199+
5. **Phase 5** — Add domain events for cross-aggregate side effects
200+
201+
Each phase is independently deployable and testable — do not attempt all at once.
202+
203+
---
204+
205+
## Step 6 — Post-review checklist
206+
207+
- [ ] Domain layer has zero imports from `org.springframework`, `jakarta.persistence`
208+
- [ ] Use case interfaces define inputs/outputs as domain types, not DTOs or JPA entities
209+
- [ ] Each aggregate has a single point of creation (factory method or constructor)
210+
- [ ] Domain events are used for cross-aggregate side effects (not direct calls)
211+
- [ ] Persistence adapter tests use `@DataJpaTest` (infrastructure); domain tests are plain JUnit
212+
213+
## Next Steps
214+
215+
- Review code quality → `/java-review`
216+
- Check SOLID compliance → `/java-solid`
217+
- Review JPA layer → `/java-jpa`
218+
- Generate architecture decision record → `/java-adr`
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
# Clean Architecture Reference Patterns
2+
3+
---
4+
5+
## Use Case interface (driving port)
6+
7+
```java
8+
// domain/port/in/CreateProductUseCase.java
9+
public interface CreateProductUseCase {
10+
Product createProduct(CreateProductCommand command);
11+
12+
record CreateProductCommand(String name, BigDecimal price, String currency, String category) {
13+
public CreateProductCommand {
14+
Objects.requireNonNull(name, "name required");
15+
Objects.requireNonNull(price, "price required");
16+
}
17+
}
18+
}
19+
```
20+
21+
---
22+
23+
## Application service (implements use case)
24+
25+
```java
26+
// application/service/ProductService.java
27+
@Service
28+
@Transactional
29+
@RequiredArgsConstructor
30+
public class ProductService implements CreateProductUseCase, GetProductUseCase {
31+
32+
private final ProductRepository productRepository; // domain port
33+
private final ApplicationEventPublisher eventPublisher; // Spring, but only here
34+
35+
@Override
36+
public Product createProduct(CreateProductCommand cmd) {
37+
Money price = new Money(cmd.price(), cmd.currency());
38+
Product product = Product.create(cmd.name(), price);
39+
Product saved = productRepository.save(product);
40+
saved.pullDomainEvents().forEach(eventPublisher::publishEvent);
41+
return saved;
42+
}
43+
44+
@Override
45+
@Transactional(readOnly = true)
46+
public Optional<Product> getProduct(ProductId id) {
47+
return productRepository.findById(id);
48+
}
49+
}
50+
```
51+
52+
---
53+
54+
## REST adapter (driving adapter — calls use case)
55+
56+
```java
57+
// infrastructure/adapter/in/web/ProductController.java
58+
@RestController
59+
@RequestMapping("/api/products")
60+
@RequiredArgsConstructor
61+
public class ProductController {
62+
63+
private final CreateProductUseCase createProduct;
64+
private final GetProductUseCase getProduct;
65+
66+
@PostMapping
67+
public ResponseEntity<ProductResponse> create(@Valid @RequestBody ProductRequest request) {
68+
var command = new CreateProductUseCase.CreateProductCommand(
69+
request.name(), request.price(), request.currency(), request.category());
70+
Product product = createProduct.createProduct(command);
71+
return ResponseEntity.status(HttpStatus.CREATED).body(ProductResponse.from(product));
72+
}
73+
74+
@GetMapping("/{id}")
75+
public ResponseEntity<ProductResponse> findById(@PathVariable Long id) {
76+
return getProduct.getProduct(new ProductId(id))
77+
.map(ProductResponse::from)
78+
.map(ResponseEntity::ok)
79+
.orElse(ResponseEntity.notFound().build());
80+
}
81+
}
82+
```
83+
84+
---
85+
86+
## JPA entity (infrastructure only — separate from domain entity)
87+
88+
```java
89+
// infrastructure/adapter/out/persistence/ProductJpaEntity.java
90+
@Entity
91+
@Table(name = "products")
92+
@Getter
93+
@Setter
94+
@NoArgsConstructor
95+
public class ProductJpaEntity {
96+
97+
@Id
98+
@GeneratedValue(strategy = GenerationType.IDENTITY)
99+
private Long id;
100+
101+
@Column(nullable = false)
102+
private String name;
103+
104+
@Column(nullable = false, precision = 19, scale = 4)
105+
private BigDecimal price;
106+
107+
@Column(nullable = false, length = 3)
108+
private String currency;
109+
110+
@Column(nullable = false)
111+
private boolean active;
112+
113+
@Column(nullable = false, updatable = false)
114+
private LocalDateTime createdAt;
115+
116+
@PrePersist
117+
void prePersist() { this.createdAt = LocalDateTime.now(); }
118+
}
119+
```
120+
121+
---
122+
123+
## Domain event
124+
125+
```java
126+
// domain/event/ProductCreatedEvent.java
127+
public record ProductCreatedEvent(ProductId productId, String name, Instant occurredOn) {
128+
public ProductCreatedEvent(ProductId productId, String name) {
129+
this(productId, name, Instant.now());
130+
}
131+
}
132+
```
133+
134+
---
135+
136+
## ArchUnit test (enforce dependency rules)
137+
138+
```java
139+
@AnalyzeClasses(packagesOf = Application.class)
140+
public class ArchitectureTest {
141+
142+
@ArchTest
143+
static final ArchRule domain_must_not_depend_on_spring =
144+
noClasses().that().resideInAPackage("..domain..")
145+
.should().dependOnClassesThat()
146+
.resideInAnyPackage("org.springframework..", "jakarta.persistence..");
147+
148+
@ArchTest
149+
static final ArchRule application_must_not_depend_on_infrastructure =
150+
noClasses().that().resideInAPackage("..application..")
151+
.should().dependOnClassesThat()
152+
.resideInAPackage("..infrastructure..");
153+
154+
@ArchTest
155+
static final ArchRule adapters_must_not_depend_on_each_other =
156+
noClasses().that().resideInAPackage("..adapter.in..")
157+
.should().dependOnClassesThat()
158+
.resideInAPackage("..adapter.out..");
159+
}
160+
```
161+
162+
Add to `pom.xml`:
163+
```xml
164+
<dependency>
165+
<groupId>com.tngtech.archunit</groupId>
166+
<artifactId>archunit-junit5</artifactId>
167+
<version>1.3.0</version>
168+
<scope>test</scope>
169+
</dependency>
170+
```

0 commit comments

Comments
 (0)