Clean Architecture in Java

Overview

Clean Architecture is a software design philosophy that emphasizes separation of concerns, testability, and maintainability. It organizes code into concentric layers with dependencies pointing inward toward the core business logic.

Core Concepts

  • Independent of Frameworks: Core business logic doesn't depend on external frameworks
  • Testable: Business rules can be tested without UI, database, or external services
  • Independent of UI: UI can change without affecting business logic
  • Independent of Database: Business rules aren't bound to database specifics
  • Independent of External Agencies: Business logic doesn't know about external interfaces

Layer Structure

┌─────────────────────────────────────────────────────────────┐
│                    External Interfaces                      │
│  ┌─────────────────┐  ┌─────────────────┐  ┌─────────────┐  │
│  │     Web Layer   │  │   Data Layer    │  │  Other APIs │  │
│  │  (Controllers)  │  │ (Repositories)  │  │             │  │
│  └─────────────────┘  └─────────────────┘  └─────────────┘  │
└─────────────────────────────────────────────────────────────┘
│
┌─────────────────────────────────────────────────────────────┐
│                    Application Layer                        │
│  ┌─────────────────┐  ┌─────────────────┐                   │
│  │   Use Cases     │  │   Services      │                   │
│  │ (Application    │  │ (Application    │                   │
│  │  Services)      │  │  Logic)         │                   │
│  └─────────────────┘  └─────────────────┘                   │
└─────────────────────────────────────────────────────────────┐
│
┌─────────────────────────────────────────────────────────────┐
│                      Domain Layer                           │
│  ┌─────────────────┐  ┌─────────────────┐  ┌─────────────┐  │
│  │    Entities     │  │  Value Objects  │  │  Domain     │  │
│  │  (Core Business │  │  (Immutable     │  │  Services   │  │
│  │    Objects)     │  │   Objects)      │  │  & Rules    │  │
│  └─────────────────┘  └─────────────────┘  └─────────────┘  │
└─────────────────────────────────────────────────────────────┘

Implementation Structure

Project Structure

src/
├── main/
│   ├── java/
│   │   └── com/
│   │       └── example/
│   │           └── myapp/
│   │               ├── domain/           # Enterprise Business Rules
│   │               │   ├── model/        # Entities & Value Objects
│   │               │   ├── repository/   # Repository Interfaces
│   │               │   ├── service/      # Domain Services
│   │               │   └── exception/    # Domain Exceptions
│   │               ├── application/      # Application Business Rules
│   │               │   ├── port/         # Input/Output Ports
│   │               │   ├── service/      # Use Cases & Application Services
│   │               │   ├── dto/          # Data Transfer Objects
│   │               │   └── exception/    # Application Exceptions
│   │               ├── infrastructure/   # Frameworks & Drivers
│   │               │   ├── persistence/  # Database Implementation
│   │               │   ├── web/          # Controllers & REST APIs
│   │               │   ├── config/       # Configuration
│   │               │   └── external/     # External Services
│   │               └── Common.java       # Shared utilities
│   └── resources/
└── test/
└── java/
└── com/
└── example/
└── myapp/
├── domain/
├── application/
└── infrastructure/

1. Domain Layer (Enterprise Business Rules)

Entities and Value Objects

// Domain Layer - Core Business Entities
package com.example.myapp.domain.model;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
// Entity - has identity
public class Order {
private final OrderId id;
private final CustomerId customerId;
private final List<OrderItem> items;
private OrderStatus status;
private final LocalDateTime createdAt;
private LocalDateTime updatedAt;
private Money totalAmount;
public Order(OrderId id, CustomerId customerId, List<OrderItem> items) {
this.id = Objects.requireNonNull(id, "Order ID cannot be null");
this.customerId = Objects.requireNonNull(customerId, "Customer ID cannot be null");
this.items = List.copyOf(Objects.requireNonNull(items, "Items cannot be null"));
this.status = OrderStatus.PENDING;
this.createdAt = LocalDateTime.now();
this.updatedAt = LocalDateTime.now();
calculateTotal();
validate();
}
// Domain logic
public void confirm() {
if (status != OrderStatus.PENDING) {
throw new IllegalOrderStateException("Only pending orders can be confirmed");
}
this.status = OrderStatus.CONFIRMED;
this.updatedAt = LocalDateTime.now();
}
public void cancel() {
if (status == OrderStatus.SHIPPED || status == OrderStatus.DELIVERED) {
throw new IllegalOrderStateException("Cannot cancel shipped or delivered orders");
}
this.status = OrderStatus.CANCELLED;
this.updatedAt = LocalDateTime.now();
}
public void addItem(OrderItem item) {
// In real implementation, you'd create a new list
// items.add(item);
calculateTotal();
this.updatedAt = LocalDateTime.now();
}
private void calculateTotal() {
this.totalAmount = items.stream()
.map(OrderItem::getSubtotal)
.reduce(Money.ZERO, Money::add);
}
private void validate() {
if (items.isEmpty()) {
throw new IllegalArgumentException("Order must have at least one item");
}
}
// Getters
public OrderId getId() { return id; }
public CustomerId getCustomerId() { return customerId; }
public List<OrderItem> getItems() { return Collections.unmodifiableList(items); }
public OrderStatus getStatus() { return status; }
public LocalDateTime getCreatedAt() { return createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
public Money getTotalAmount() { return totalAmount; }
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Order)) return false;
Order order = (Order) o;
return Objects.equals(id, order.id);
}
@Override
public int hashCode() {
return Objects.hash(id);
}
}
// Value Object - immutable and has no identity
public class Money {
public static final Money ZERO = new Money(BigDecimal.ZERO);
private final BigDecimal amount;
private final String currency;
public Money(BigDecimal amount) {
this(amount, "USD");
}
public Money(BigDecimal amount, String currency) {
this.amount = Objects.requireNonNull(amount, "Amount cannot be null")
.setScale(2, BigDecimal.ROUND_HALF_EVEN);
this.currency = Objects.requireNonNull(currency, "Currency cannot be null");
validate();
}
public Money add(Money other) {
if (!this.currency.equals(other.currency)) {
throw new IllegalArgumentException("Cannot add different currencies");
}
return new Money(this.amount.add(other.amount), this.currency);
}
public Money multiply(int quantity) {
return new Money(this.amount.multiply(BigDecimal.valueOf(quantity)), this.currency);
}
private void validate() {
if (amount.compareTo(BigDecimal.ZERO) < 0) {
throw new IllegalArgumentException("Money amount cannot be negative");
}
}
// Getters and equals/hashCode
public BigDecimal getAmount() { return amount; }
public String getCurrency() { return currency; }
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Money)) return false;
Money money = (Money) o;
return Objects.equals(amount, money.amount) && 
Objects.equals(currency, money.currency);
}
@Override
public int hashCode() {
return Objects.hash(amount, currency);
}
}
// Value Objects for IDs
public class OrderId {
private final String value;
public OrderId(String value) {
this.value = Objects.requireNonNull(value, "Order ID value cannot be null");
if (value.trim().isEmpty()) {
throw new IllegalArgumentException("Order ID cannot be empty");
}
}
public String getValue() { return value; }
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof OrderId)) return false;
OrderId orderId = (OrderId) o;
return Objects.equals(value, orderId.value);
}
@Override
public int hashCode() {
return Objects.hash(value);
}
@Override
public String toString() {
return value;
}
}
public class CustomerId {
private final Long value;
public CustomerId(Long value) {
this.value = Objects.requireNonNull(value, "Customer ID cannot be null");
}
public Long getValue() { return value; }
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof CustomerId)) return false;
CustomerId that = (CustomerId) o;
return Objects.equals(value, that.value);
}
@Override
public int hashCode() {
return Objects.hash(value);
}
}
// Entity for Order Items
public class OrderItem {
private final ProductId productId;
private final String productName;
private final Money price;
private final int quantity;
public OrderItem(ProductId productId, String productName, Money price, int quantity) {
this.productId = Objects.requireNonNull(productId, "Product ID cannot be null");
this.productName = Objects.requireNonNull(productName, "Product name cannot be null");
this.price = Objects.requireNonNull(price, "Price cannot be null");
this.quantity = quantity;
validate();
}
public Money getSubtotal() {
return price.multiply(quantity);
}
private void validate() {
if (quantity <= 0) {
throw new IllegalArgumentException("Quantity must be positive");
}
}
// Getters
public ProductId getProductId() { return productId; }
public String getProductName() { return productName; }
public Money getPrice() { return price; }
public int getQuantity() { return quantity; }
}
// Enums
public enum OrderStatus {
PENDING, CONFIRMED, SHIPPED, DELIVERED, CANCELLED
}
// Domain Exceptions
package com.example.myapp.domain.exception;
public class IllegalOrderStateException extends RuntimeException {
public IllegalOrderStateException(String message) {
super(message);
}
}

Repository Interfaces (Ports)

// Domain Layer - Repository Interfaces (Ports)
package com.example.myapp.domain.repository;
import com.example.myapp.domain.model.Order;
import com.example.myapp.domain.model.OrderId;
import com.example.myapp.domain.model.CustomerId;
import java.util.List;
import java.util.Optional;
public interface OrderRepository {
Order save(Order order);
Optional<Order> findById(OrderId orderId);
List<Order> findByCustomerId(CustomerId customerId);
List<Order> findAll();
void delete(OrderId orderId);
boolean existsById(OrderId orderId);
}
package com.example.myapp.domain.repository;
import com.example.myapp.domain.model.Customer;
import com.example.myapp.domain.model.CustomerId;
import java.util.Optional;
public interface CustomerRepository {
Customer save(Customer customer);
Optional<Customer> findById(CustomerId customerId);
boolean existsByEmail(String email);
}

Domain Services

// Domain Layer - Domain Services
package com.example.myapp.domain.service;
import com.example.myapp.domain.model.Order;
import com.example.myapp.domain.model.OrderId;
import com.example.myapp.domain.repository.OrderRepository;
import com.example.myapp.domain.exception.OrderNotFoundException;
public class OrderValidationService {
private final OrderRepository orderRepository;
public OrderValidationService(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
public void validateOrderExists(OrderId orderId) {
if (!orderRepository.existsById(orderId)) {
throw new OrderNotFoundException("Order not found with ID: " + orderId);
}
}
public void validateOrderCanBeCancelled(Order order) {
if (order.getStatus().equals(OrderStatus.SHIPPED) || 
order.getStatus().equals(OrderStatus.DELIVERED)) {
throw new IllegalStateException("Cannot cancel shipped or delivered order");
}
}
}

2. Application Layer (Application Business Rules)

Use Cases and Application Services

// Application Layer - Use Cases/Application Services
package com.example.myapp.application.service;
import com.example.myapp.application.port.input.OrderUseCase;
import com.example.myapp.application.port.output.OrderRepository;
import com.example.myapp.application.port.output.CustomerRepository;
import com.example.myapp.domain.model.*;
import com.example.myapp.application.dto.CreateOrderCommand;
import com.example.myapp.application.dto.OrderResponse;
import com.example.myapp.domain.service.OrderValidationService;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.stream.Collectors;
public class OrderApplicationService implements OrderUseCase {
private final OrderRepository orderRepository;
private final CustomerRepository customerRepository;
private final OrderValidationService orderValidationService;
public OrderApplicationService(OrderRepository orderRepository,
CustomerRepository customerRepository,
OrderValidationService orderValidationService) {
this.orderRepository = orderRepository;
this.customerRepository = customerRepository;
this.orderValidationService = orderValidationService;
}
@Override
@Transactional
public OrderResponse createOrder(CreateOrderCommand command) {
// Validate customer exists
CustomerId customerId = new CustomerId(command.getCustomerId());
customerRepository.findById(customerId)
.orElseThrow(() -> new CustomerNotFoundException("Customer not found"));
// Create order items
List<OrderItem> orderItems = command.getItems().stream()
.map(item -> new OrderItem(
new ProductId(item.getProductId()),
item.getProductName(),
new Money(item.getPrice()),
item.getQuantity()
))
.collect(Collectors.toList());
// Create and save order
Order order = new Order(
generateOrderId(),
customerId,
orderItems
);
Order savedOrder = orderRepository.save(order);
return OrderResponse.from(savedOrder);
}
@Override
@Transactional
public OrderResponse confirmOrder(OrderId orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException("Order not found: " + orderId));
order.confirm();
Order updatedOrder = orderRepository.save(order);
return OrderResponse.from(updatedOrder);
}
@Override
@Transactional
public OrderResponse cancelOrder(OrderId orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException("Order not found: " + orderId));
orderValidationService.validateOrderCanBeCancelled(order);
order.cancel();
Order updatedOrder = orderRepository.save(order);
return OrderResponse.from(updatedOrder);
}
@Override
@Transactional(readOnly = true)
public OrderResponse getOrder(OrderId orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException("Order not found: " + orderId));
return OrderResponse.from(order);
}
@Override
@Transactional(readOnly = true)
public List<OrderResponse> getCustomerOrders(CustomerId customerId) {
return orderRepository.findByCustomerId(customerId).stream()
.map(OrderResponse::from)
.collect(Collectors.toList());
}
private OrderId generateOrderId() {
// In real application, use a proper ID generation strategy
return new OrderId("ORD-" + System.currentTimeMillis());
}
}

Input/Output Ports

// Application Layer - Input Ports
package com.example.myapp.application.port.input;
import com.example.myapp.application.dto.CreateOrderCommand;
import com.example.myapp.application.dto.OrderResponse;
import com.example.myapp.domain.model.OrderId;
import com.example.myapp.domain.model.CustomerId;
import java.util.List;
public interface OrderUseCase {
OrderResponse createOrder(CreateOrderCommand command);
OrderResponse confirmOrder(OrderId orderId);
OrderResponse cancelOrder(OrderId orderId);
OrderResponse getOrder(OrderId orderId);
List<OrderResponse> getCustomerOrders(CustomerId customerId);
}
// Application Layer - Output Ports
package com.example.myapp.application.port.output;
import com.example.myapp.domain.model.Order;
import com.example.myapp.domain.model.OrderId;
import com.example.myapp.domain.model.CustomerId;
import java.util.List;
import java.util.Optional;
public interface OrderRepository {
Order save(Order order);
Optional<Order> findById(OrderId orderId);
List<Order> findByCustomerId(CustomerId customerId);
List<Order> findAll();
void delete(OrderId orderId);
boolean existsById(OrderId orderId);
}
package com.example.myapp.application.port.output;
import com.example.myapp.domain.model.Customer;
import com.example.myapp.domain.model.CustomerId;
import java.util.Optional;
public interface CustomerRepository {
Optional<Customer> findById(CustomerId customerId);
boolean existsByEmail(String email);
}

DTOs (Data Transfer Objects)

// Application Layer - DTOs
package com.example.myapp.application.dto;
import com.example.myapp.domain.model.*;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class CreateOrderCommand {
private Long customerId;
private List<OrderItemDto> items;
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class OrderItemDto {
private String productId;
private String productName;
private BigDecimal price;
private int quantity;
}
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public class OrderResponse {
private String orderId;
private Long customerId;
private List<OrderItemResponse> items;
private String status;
private BigDecimal totalAmount;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
public static OrderResponse from(Order order) {
List<OrderItemResponse> itemResponses = order.getItems().stream()
.map(OrderItemResponse::from)
.collect(Collectors.toList());
return new OrderResponse(
order.getId().getValue(),
order.getCustomerId().getValue(),
itemResponses,
order.getStatus().name(),
order.getTotalAmount().getAmount(),
order.getCreatedAt(),
order.getUpdatedAt()
);
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class OrderItemResponse {
private String productId;
private String productName;
private BigDecimal price;
private int quantity;
private BigDecimal subtotal;
public static OrderItemResponse from(OrderItem item) {
return new OrderItemResponse(
item.getProductId().getValue(),
item.getProductName(),
item.getPrice().getAmount(),
item.getQuantity(),
item.getSubtotal().getAmount()
);
}
}
}

Application Exceptions

// Application Layer - Exceptions
package com.example.myapp.application.exception;
public class OrderNotFoundException extends RuntimeException {
public OrderNotFoundException(String message) {
super(message);
}
}
public class CustomerNotFoundException extends RuntimeException {
public CustomerNotFoundException(String message) {
super(message);
}
}
public class InvalidOrderException extends RuntimeException {
public InvalidOrderException(String message) {
super(message);
}
}

3. Infrastructure Layer (Frameworks & Drivers)

Persistence Implementation

// Infrastructure Layer - Persistence
package com.example.myapp.infrastructure.persistence;
import com.example.myapp.application.port.output.OrderRepository;
import com.example.myapp.domain.model.*;
import com.example.myapp.infrastructure.persistence.entity.OrderEntity;
import com.example.myapp.infrastructure.persistence.entity.OrderItemEntity;
import com.example.myapp.infrastructure.persistence.jpa.JpaOrderRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
@Repository
public class OrderRepositoryImpl implements OrderRepository {
private final JpaOrderRepository jpaOrderRepository;
private final OrderEntityMapper orderEntityMapper;
public OrderRepositoryImpl(JpaOrderRepository jpaOrderRepository, 
OrderEntityMapper orderEntityMapper) {
this.jpaOrderRepository = jpaOrderRepository;
this.orderEntityMapper = orderEntityMapper;
}
@Override
public Order save(Order order) {
OrderEntity orderEntity = orderEntityMapper.toEntity(order);
OrderEntity savedEntity = jpaOrderRepository.save(orderEntity);
return orderEntityMapper.toDomain(savedEntity);
}
@Override
public Optional<Order> findById(OrderId orderId) {
return jpaOrderRepository.findById(orderId.getValue())
.map(orderEntityMapper::toDomain);
}
@Override
public List<Order> findByCustomerId(CustomerId customerId) {
return jpaOrderRepository.findByCustomerId(customerId.getValue()).stream()
.map(orderEntityMapper::toDomain)
.collect(Collectors.toList());
}
@Override
public List<Order> findAll() {
return jpaOrderRepository.findAll().stream()
.map(orderEntityMapper::toDomain)
.collect(Collectors.toList());
}
@Override
public void delete(OrderId orderId) {
jpaOrderRepository.deleteById(orderId.getValue());
}
@Override
public boolean existsById(OrderId orderId) {
return jpaOrderRepository.existsById(orderId.getValue());
}
}
// Entity Mapper
package com.example.myapp.infrastructure.persistence;
import com.example.myapp.domain.model.*;
import com.example.myapp.infrastructure.persistence.entity.OrderEntity;
import com.example.myapp.infrastructure.persistence.entity.OrderItemEntity;
import org.springframework.stereotype.Component;
import java.util.stream.Collectors;
@Component
public class OrderEntityMapper {
public OrderEntity toEntity(Order order) {
List<OrderItemEntity> itemEntities = order.getItems().stream()
.map(this::toItemEntity)
.collect(Collectors.toList());
return OrderEntity.builder()
.id(order.getId().getValue())
.customerId(order.getCustomerId().getValue())
.items(itemEntities)
.status(order.getStatus().name())
.totalAmount(order.getTotalAmount().getAmount())
.createdAt(order.getCreatedAt())
.updatedAt(order.getUpdatedAt())
.build();
}
public Order toDomain(OrderEntity entity) {
List<OrderItem> orderItems = entity.getItems().stream()
.map(this::toDomainItem)
.collect(Collectors.toList());
Order order = new Order(
new OrderId(entity.getId()),
new CustomerId(entity.getCustomerId()),
orderItems
);
// Set status and other properties that might not be in constructor
// This would require reflection or a different design
return order;
}
private OrderItemEntity toItemEntity(OrderItem item) {
return OrderItemEntity.builder()
.productId(item.getProductId().getValue())
.productName(item.getProductName())
.price(item.getPrice().getAmount())
.quantity(item.getQuantity())
.build();
}
private OrderItem toDomainItem(OrderItemEntity entity) {
return new OrderItem(
new ProductId(entity.getProductId()),
entity.getProductName(),
new Money(entity.getPrice()),
entity.getQuantity()
);
}
}

JPA Entities

// Infrastructure Layer - JPA Entities
package com.example.myapp.infrastructure.persistence.entity;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import javax.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "orders")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class OrderEntity {
@Id
private String id;
@Column(name = "customer_id", nullable = false)
private Long customerId;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private String status;
@Column(name = "total_amount", precision = 10, scale = 2)
private BigDecimal totalAmount;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
@Builder.Default
private List<OrderItemEntity> items = new ArrayList<>();
}
@Entity
@Table(name = "order_items")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class OrderItemEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "order_id")
private OrderEntity order;
@Column(name = "product_id", nullable = false)
private String productId;
@Column(name = "product_name", nullable = false)
private String productName;
@Column(precision = 10, scale = 2)
private BigDecimal price;
@Column(nullable = false)
private Integer quantity;
}
// JPA Repository Interface
package com.example.myapp.infrastructure.persistence.jpa;
import com.example.myapp.infrastructure.persistence.entity.OrderEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface JpaOrderRepository extends JpaRepository<OrderEntity, String> {
List<OrderEntity> findByCustomerId(Long customerId);
}

Web Layer (Controllers)

// Infrastructure Layer - Web Controllers
package com.example.myapp.infrastructure.web;
import com.example.myapp.application.port.input.OrderUseCase;
import com.example.myapp.application.dto.CreateOrderCommand;
import com.example.myapp.application.dto.OrderResponse;
import com.example.myapp.domain.model.OrderId;
import com.example.myapp.domain.model.CustomerId;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
import java.net.URI;
import java.util.List;
@RestController
@RequestMapping("/api/orders")
@RequiredArgsConstructor
public class OrderController {
private final OrderUseCase orderUseCase;
@PostMapping
public ResponseEntity<OrderResponse> createOrder(@Valid @RequestBody CreateOrderCommand command) {
OrderResponse response = orderUseCase.createOrder(command);
return ResponseEntity.created(URI.create("/api/orders/" + response.getOrderId()))
.body(response);
}
@GetMapping("/{orderId}")
public ResponseEntity<OrderResponse> getOrder(@PathVariable String orderId) {
OrderResponse response = orderUseCase.getOrder(new OrderId(orderId));
return ResponseEntity.ok(response);
}
@PostMapping("/{orderId}/confirm")
public ResponseEntity<OrderResponse> confirmOrder(@PathVariable String orderId) {
OrderResponse response = orderUseCase.confirmOrder(new OrderId(orderId));
return ResponseEntity.ok(response);
}
@PostMapping("/{orderId}/cancel")
public ResponseEntity<OrderResponse> cancelOrder(@PathVariable String orderId) {
OrderResponse response = orderUseCase.cancelOrder(new OrderId(orderId));
return ResponseEntity.ok(response);
}
@GetMapping("/customer/{customerId}")
public ResponseEntity<List<OrderResponse>> getCustomerOrders(@PathVariable Long customerId) {
List<OrderResponse> responses = orderUseCase.getCustomerOrders(new CustomerId(customerId));
return ResponseEntity.ok(responses);
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<String> handleIllegalArgument(IllegalArgumentException ex) {
return ResponseEntity.badRequest().body(ex.getMessage());
}
}

Configuration

// Infrastructure Layer - Configuration
package com.example.myapp.infrastructure.config;
import com.example.myapp.application.port.input.OrderUseCase;
import com.example.myapp.application.port.output.OrderRepository;
import com.example.myapp.application.port.output.CustomerRepository;
import com.example.myapp.application.service.OrderApplicationService;
import com.example.myapp.domain.service.OrderValidationService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class ApplicationConfig {
@Bean
public OrderUseCase orderUseCase(OrderRepository orderRepository,
CustomerRepository customerRepository,
OrderValidationService orderValidationService) {
return new OrderApplicationService(orderRepository, customerRepository, orderValidationService);
}
@Bean
public OrderValidationService orderValidationService(OrderRepository orderRepository) {
return new OrderValidationService(orderRepository);
}
}
// Web Configuration
package com.example.myapp.infrastructure.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("http://localhost:3000")
.allowedMethods("GET", "POST", "PUT", "DELETE");
}
}

4. Testing

Domain Layer Tests

// Domain Layer Tests
package com.example.myapp.domain.model;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class OrderTest {
@Test
void shouldCreateOrderWithValidData() {
OrderId orderId = new OrderId("ORD-123");
CustomerId customerId = new CustomerId(1L);
List<OrderItem> items = List.of(
new OrderItem(new ProductId("P1"), "Product 1", new Money(new BigDecimal("10.00")), 2)
);
Order order = new Order(orderId, customerId, items);
assertNotNull(order);
assertEquals(orderId, order.getId());
assertEquals(customerId, order.getCustomerId());
assertEquals(1, order.getItems().size());
assertEquals(OrderStatus.PENDING, order.getStatus());
}
@Test
void shouldCalculateTotalAmountCorrectly() {
Order order = new Order(
new OrderId("ORD-123"),
new CustomerId(1L),
List.of(
new OrderItem(new ProductId("P1"), "Product 1", new Money(new BigDecimal("10.00")), 2),
new OrderItem(new ProductId("P2"), "Product 2", new Money(new BigDecimal("15.00")), 1)
)
);
assertEquals(new BigDecimal("35.00"), order.getTotalAmount().getAmount());
}
@Test
void shouldConfirmPendingOrder() {
Order order = createSampleOrder();
order.confirm();
assertEquals(OrderStatus.CONFIRMED, order.getStatus());
}
@Test
void shouldNotConfirmNonPendingOrder() {
Order order = createSampleOrder();
order.confirm();
assertThrows(IllegalOrderStateException.class, order::confirm);
}
private Order createSampleOrder() {
return new Order(
new OrderId("ORD-123"),
new CustomerId(1L),
List.of(
new OrderItem(new ProductId("P1"), "Product 1", new Money(new BigDecimal("10.00")), 1)
)
);
}
}
class MoneyTest {
@Test
void shouldAddMoneyOfSameCurrency() {
Money money1 = new Money(new BigDecimal("10.00"));
Money money2 = new Money(new BigDecimal("15.50"));
Money result = money1.add(money2);
assertEquals(new BigDecimal("25.50"), result.getAmount());
}
@Test
void shouldNotAddMoneyOfDifferentCurrencies() {
Money usd = new Money(new BigDecimal("10.00"), "USD");
Money eur = new Money(new BigDecimal("15.00"), "EUR");
assertThrows(IllegalArgumentException.class, () -> usd.add(eur));
}
}

Application Layer Tests

// Application Layer Tests
package com.example.myapp.application.service;
import com.example.myapp.application.dto.CreateOrderCommand;
import com.example.myapp.application.port.output.OrderRepository;
import com.example.myapp.application.port.output.CustomerRepository;
import com.example.myapp.domain.model.*;
import com.example.myapp.domain.service.OrderValidationService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.math.BigDecimal;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class OrderApplicationServiceTest {
@Mock
private OrderRepository orderRepository;
@Mock
private CustomerRepository customerRepository;
@Mock
private OrderValidationService orderValidationService;
@InjectMocks
private OrderApplicationService orderService;
@Test
void shouldCreateOrderSuccessfully() {
// Given
Long customerId = 1L;
Customer customer = new Customer(new CustomerId(customerId), "John Doe", "[email protected]");
when(customerRepository.findById(new CustomerId(customerId))).thenReturn(Optional.of(customer));
when(orderRepository.save(any(Order.class))).thenAnswer(invocation -> invocation.getArgument(0));
CreateOrderCommand command = new CreateOrderCommand();
command.setCustomerId(customerId);
command.setItems(List.of(
new CreateOrderCommand.OrderItemDto("P1", "Product 1", new BigDecimal("10.00"), 2)
));
// When
var response = orderService.createOrder(command);
// Then
assertNotNull(response);
assertNotNull(response.getOrderId());
assertEquals(customerId, response.getCustomerId());
verify(orderRepository).save(any(Order.class));
}
@Test
void shouldThrowExceptionWhenCustomerNotFound() {
// Given
Long customerId = 999L;
when(customerRepository.findById(new CustomerId(customerId))).thenReturn(Optional.empty());
CreateOrderCommand command = new CreateOrderCommand();
command.setCustomerId(customerId);
command.setItems(List.of(
new CreateOrderCommand.OrderItemDto("P1", "Product 1", new BigDecimal("10.00"), 1)
));
// When & Then
assertThrows(CustomerNotFoundException.class, () -> orderService.createOrder(command));
}
}

Integration Tests

// Integration Tests
package com.example.myapp.infrastructure.web;
import com.example.myapp.application.dto.CreateOrderCommand;
import com.example.myapp.application.port.input.OrderUseCase;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import java.math.BigDecimal;
import java.util.List;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@WebMvcTest(OrderController.class)
class OrderControllerIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@MockBean
private OrderUseCase orderUseCase;
@Test
void shouldCreateOrderViaRestApi() throws Exception {
// Given
CreateOrderCommand command = new CreateOrderCommand();
command.setCustomerId(1L);
command.setItems(List.of(
new CreateOrderCommand.OrderItemDto("P1", "Product 1", new BigDecimal("10.00"), 2)
));
when(orderUseCase.createOrder(any(CreateOrderCommand.class)))
.thenReturn(new OrderResponse("ORD-123", 1L, List.of(), "PENDING", 
new BigDecimal("20.00"), null, null));
// When & Then
mockMvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(command)))
.andExpect(status().isCreated())
.andExpect(header().exists("Location"))
.andExpect(jsonPath("$.orderId").value("ORD-123"));
}
}

5. Common Utilities and Shared Kernel

// Shared Utilities
package com.example.myapp;
public final class Common {
private Common() {
// Utility class
}
public static void validateNotNull(Object obj, String message) {
if (obj == null) {
throw new IllegalArgumentException(message);
}
}
public static void validateNotEmpty(String str, String message) {
if (str == null || str.trim().isEmpty()) {
throw new IllegalArgumentException(message);
}
}
}
// Custom Annotations
package com.example.myapp.domain.annotation;
import java.lang.annotation.*;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface DomainService {
}
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface ApplicationService {
}
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface DomainEntity {
}

Key Benefits of Clean Architecture

  1. Testability: Each layer can be tested independently
  2. Maintainability: Clear separation of concerns makes code easier to maintain
  3. Flexibility: Easy to change frameworks, databases, or UI without affecting business logic
  4. Independence: Business rules are independent of external concerns
  5. Scalability: Well-defined boundaries make it easier to scale and evolve the system

Dependency Rule

The fundamental rule of Clean Architecture is the Dependency Rule:

  • Source code dependencies must point only inward, toward higher-level policies
  • Nothing in an inner circle can know anything at all about something in an outer circle

This architecture ensures that your business logic remains pure and independent, making your application more resilient to changes in external concerns like databases, frameworks, and delivery mechanisms.

Leave a Reply

Your email address will not be published. Required fields are marked *


Macro Nepal Helper