Architecture patterns provide proven solutions to recurring software design problems. For Java applications, selecting the right architecture pattern is crucial for building scalable, maintainable, and robust systems. This guide explores essential architecture patterns for Java applications, providing practical implementation examples and best practices.
Key benefits of using proper architecture patterns:
The Layered Architecture Pattern organizes code into horizontal layers, each with a specific responsibility. It's one of the most common patterns for Java enterprise applications.
Layer | Responsibility |
---|---|
Presentation Layer | User interface, HTTP requests handling |
Application Layer | Business logic orchestration, use case implementation |
Domain Layer | Core business entities and rules |
Infrastructure Layer | Data persistence, external services integration |
// Presentation Layer (Controller)
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserService userService;
// Constructor injection
public UserController(UserService userService) {
this.userService = userService;
}
@GetMapping("/{id}")
public ResponseEntity<UserDto> getUser(@PathVariable Long id) {
return ResponseEntity.ok(userService.getUserById(id));
}
// Other endpoints
}
// Application Layer (Service)
@Service
public class UserServiceImpl implements UserService {
private final UserRepository userRepository;
private final UserMapper userMapper;
// Constructor injection
public UserServiceImpl(UserRepository userRepository, UserMapper userMapper) {
this.userRepository = userRepository;
this.userMapper = userMapper;
}
@Override
public UserDto getUserById(Long id) {
User user = userRepository.findById(id)
.orElseThrow(() -> new UserNotFoundException(id));
return userMapper.toUserDto(user);
}
// Other business methods
}
// Domain Layer (Entity)
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String firstName;
private String lastName;
private String email;
// Getters, setters, business methods
}
// Infrastructure Layer (Repository)
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
}
Microservices architecture structures an application as a collection of loosely coupled, independently deployable services, each focusing on a specific business capability.
// UserService microservice using Spring Boot
@SpringBootApplication
public class UserServiceApplication {
public static void main(String[] args) {
SpringApplication.run(UserServiceApplication.class, args);
}
}
// Inter-service communication using RestTemplate
@Service
public class OrderService {
private final RestTemplate restTemplate;
private final String userServiceUrl;
public OrderService(RestTemplate restTemplate,
@Value("${services.user-service.url}") String userServiceUrl) {
this.restTemplate = restTemplate;
this.userServiceUrl = userServiceUrl;
}
public OrderDto createOrder(OrderRequest request) {
// Validate user exists by calling user service
UserDto user = restTemplate.getForObject(
userServiceUrl + "/users/{id}",
UserDto.class,
request.getUserId()
);
// Process order...
return new OrderDto();
}
}
// Service discovery with Spring Cloud Netflix Eureka
@SpringBootApplication
@EnableEurekaServer
public class ServiceRegistryApplication {
public static void main(String[] args) {
SpringApplication.run(ServiceRegistryApplication.class, args);
}
}
// Client-side load balancing
@Configuration
public class LoadBalancerConfig {
@Bean
@LoadBalanced
public RestTemplate restTemplate() {
return new RestTemplate();
}
}
Hexagonal Architecture (also known as Ports and Adapters) isolates the core business logic from external concerns like UI, database, and external services by defining ports (interfaces) and adapters.
// Domain model (core business logic)
public class Product {
private ProductId id;
private String name;
private Money price;
private int stockQuantity;
public void decreaseStock(int quantity) {
if (quantity > stockQuantity) {
throw new InsufficientStockException(id, quantity, stockQuantity);
}
this.stockQuantity -= quantity;
}
// Other business methods and properties
}
// Port (outgoing interface)
public interface ProductRepository {
Optional<Product> findById(ProductId id);
void save(Product product);
}
// Port (incoming interface)
public interface ProductService {
ProductDto getProduct(String id);
void updateStock(String id, int quantity);
}
// Adapter (implementing outgoing port)
@Repository
public class JpaProductRepository implements ProductRepository {
private final SpringDataProductRepository repository;
private final ProductMapper mapper;
public JpaProductRepository(SpringDataProductRepository repository,
ProductMapper mapper) {
this.repository = repository;
this.mapper = mapper;
}
@Override
public Optional<Product> findById(ProductId id) {
return repository.findById(id.getValue())
.map(mapper::toDomain);
}
@Override
public void save(Product product) {
ProductEntity entity = mapper.toEntity(product);
repository.save(entity);
}
}
// Adapter (implementing incoming port)
@Service
public class ProductServiceImpl implements ProductService {
private final ProductRepository productRepository;
private final ProductMapper mapper;
// Constructor injection
@Override
public ProductDto getProduct(String id) {
Product product = productRepository.findById(new ProductId(id))
.orElseThrow(() -> new ProductNotFoundException(id));
return mapper.toDto(product);
}
@Override
public void updateStock(String id, int quantity) {
Product product = productRepository.findById(new ProductId(id))
.orElseThrow(() -> new ProductNotFoundException(id));
product.decreaseStock(quantity);
productRepository.save(product);
}
}
CQRS separates read (queries) and write (commands) operations into different models, allowing them to be optimized independently. It's often used with Event Sourcing for complex domains.
// Command model
public class CreateOrderCommand {
private final String customerId;
private final List<OrderItemDto> items;
// Constructor, getters
}
// Command handler
@Service
public class OrderCommandHandler {
private final OrderRepository orderRepository;
private final EventPublisher eventPublisher;
public String handle(CreateOrderCommand command) {
// Create and validate order
Order order = Order.create(
new CustomerId(command.getCustomerId()),
command.getItems().stream()
.map(item -> new OrderItem(item.getProductId(), item.getQuantity()))
.collect(Collectors.toList())
);
// Save and publish event
orderRepository.save(order);
eventPublisher.publish(new OrderCreatedEvent(order.getId(), order.getCustomerId()));
return order.getId().toString();
}
}
// Query model (optimized for reading)
@Entity
@Table(name = "order_summaries")
public class OrderSummary {
@Id
private String id;
private String customerId;
private String customerName;
private BigDecimal totalAmount;
private LocalDateTime createdAt;
private String status;
// Getters, setters
}
// Query repository
public interface OrderSummaryRepository extends JpaRepository<OrderSummary, String> {
List<OrderSummary> findByCustomerId(String customerId);
}
// Query service
@Service
public class OrderQueryService {
private final OrderSummaryRepository repository;
public List<OrderSummaryDto> getOrdersByCustomer(String customerId) {
return repository.findByCustomerId(customerId).stream()
.map(this::toDto)
.collect(Collectors.toList());
}
private OrderSummaryDto toDto(OrderSummary summary) {
// Mapping logic
return new OrderSummaryDto(/* mapped fields */);
}
}
Event Sourcing captures all changes to the application state as a sequence of events. Instead of storing current state, it rebuilds state by replaying events from the beginning.
// Domain event
public interface DomainEvent {
String getAggregateId();
LocalDateTime getOccurredAt();
}
public class OrderPlacedEvent implements DomainEvent {
private final String orderId;
private final String customerId;
private final List<OrderItemDto> items;
private final LocalDateTime occurredAt;
// Constructor, getters
@Override
public String getAggregateId() {
return orderId;
}
@Override
public LocalDateTime getOccurredAt() {
return occurredAt;
}
}
// Event store
public interface EventStore {
void saveEvents(String aggregateId, List<DomainEvent> events, int expectedVersion);
List<DomainEvent> getEvents(String aggregateId);
}
// Aggregate root with event sourcing
public class Order {
private String id;
private String customerId;
private List<OrderItem> items = new ArrayList<>();
private OrderStatus status;
private int version = -1;
private List<DomainEvent> changes = new ArrayList<>();
// Factory method
public static Order create(String customerId, List<OrderItem> items) {
Order order = new Order();
order.apply(new OrderPlacedEvent(
UUID.randomUUID().toString(),
customerId,
items.stream().map(OrderItemDto::from).collect(Collectors.toList()),
LocalDateTime.now()
));
return order;
}
// Apply an event and update state
private void apply(OrderPlacedEvent event) {
this.id = event.getOrderId();
this.customerId = event.getCustomerId();
this.items = event.getItems().stream()
.map(OrderItem::from)
.collect(Collectors.toList());
this.status = OrderStatus.PLACED;
// Track the event as a change
changes.add(event);
}
// Load from history
public static Order loadFromHistory(List<DomainEvent> events) {
Order order = new Order();
events.forEach(event -> {
order.version++;
if (event instanceof OrderPlacedEvent) {
order.apply((OrderPlacedEvent) event);
} else if (event instanceof OrderShippedEvent) {
order.apply((OrderShippedEvent) event);
}
// Handle other event types
});
return order;
}
// Get uncommitted changes
public List<DomainEvent> getUncommittedChanges() {
return Collections.unmodifiableList(changes);
}
// Mark changes as committed
public void markChangesAsCommitted() {
changes.clear();
}
// Business methods
public void ship() {
if (status != OrderStatus.PLACED) {
throw new InvalidOrderStateException("Cannot ship order that is not in PLACED state");
}
apply(new OrderShippedEvent(id, LocalDateTime.now()));
}
private void apply(OrderShippedEvent event) {
this.status = OrderStatus.SHIPPED;
changes.add(event);
}
}
Domain-Driven Design is an approach to software development that focuses on creating a model of the domain that both reflects business reality and informs software design.
// Value Object
public class Money {
private final BigDecimal amount;
private final Currency currency;
public Money(BigDecimal amount, Currency currency) {
this.amount = amount;
this.currency = currency;
}
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 multiplier) {
return new Money(this.amount.multiply(BigDecimal.valueOf(multiplier)), this.currency);
}
// Other methods, equals, hashCode
}
// Entity
public class Order {
private OrderId id;
private CustomerId customerId;
private Set<OrderLine> orderLines;
private OrderStatus status;
// Constructor, business methods
public void addOrderLine(Product product, int quantity) {
OrderLine line = new OrderLine(product.getId(), product.getName(),
product.getPrice(), quantity);
orderLines.add(line);
}
public Money calculateTotal() {
return orderLines.stream()
.map(OrderLine::getLineTotal)
.reduce(Money.ZERO, Money::add);
}
}
// Aggregate Root
public class Customer {
private CustomerId id;
private String name;
private ContactInformation contactInfo;
private Set<Address> addresses;
private Address defaultShippingAddress;
// Business methods
public void updateContactInformation(ContactInformation newContactInfo) {
this.contactInfo = newContactInfo;
// Domain event could be raised here
}
public void addAddress(Address address) {
addresses.add(address);
if (addresses.size() == 1) {
defaultShippingAddress = address;
}
}
}
// Repository
public interface CustomerRepository {
Customer findById(CustomerId id);
void save(Customer customer);
}
// Domain Service
public class OrderDomainService {
public Order createOrder(Customer customer, List<OrderLineItem> items,
InventoryService inventoryService) {
// Business logic that doesn't belong in entities
Order order = new Order(new OrderId(), customer.getId());
for (OrderLineItem item : items) {
Product product = inventoryService.findProduct(item.getProductId());
if (product == null) {
throw new ProductNotFoundException(item.getProductId());
}
if (!inventoryService.isInStock(item.getProductId(), item.getQuantity())) {
throw new InsufficientInventoryException(item.getProductId());
}
order.addOrderLine(product, item.getQuantity());
}
return order;
}
}
Choosing and implementing the right architecture pattern is a critical decision that shapes the future of your Java application. While each pattern offers unique benefits, the best approach often involves combining elements from different patterns to address specific business and technical requirements.
Remember that architecture is not a one-time decision but an ongoing process of refinement. As your application evolves, your architecture should adapt to new challenges and opportunities, always keeping the business domain at the center of your design decisions.