- Published on
The Complete Guide to Modern Java Architecture - Part 2: Architecture Patterns
- Authors
- Name
- Gary Huynh
- @gary_atruedev
The Complete Guide to Modern Java Architecture - Part 2: Architecture Patterns
This is Part 2 of a comprehensive 5-part series on Modern Java Architecture. Building on the foundational principles from Part 1, we now explore concrete architectural patterns that shape how we build systems in 2025.
Series Overview:
- Part 1: Foundation - Evolution, principles, and modern Java features
- Part 2: Architecture Patterns (This post) - Monoliths, microservices, and event-driven design
- Part 3: Implementation Deep Dives - APIs, data layer, security, and observability
- Part 4: Performance & Scalability - Optimization, reactive programming, and scaling patterns
- Part 5: Production Considerations - Deployment, containers, and operational excellence
The question isn't whether to build a monolith or microservices—it's understanding which pattern serves your specific context. After architecting systems across the full spectrum from startup MVPs to enterprise platforms handling billions of requests, I've learned that architecture patterns are tools, each with distinct trade-offs.
In 2025, the landscape has matured significantly. We have battle-tested patterns, proven frameworks, and enough production experience to know what works. This part provides a practical framework for choosing and implementing the right architectural pattern for your system.
The Architecture Decision Framework
Context-Driven Architecture Selection
Before diving into specific patterns, let's establish a decision framework:
public record ArchitectureContext(
TeamSize teamSize,
DomainComplexity domainComplexity,
ScaleRequirements scaleRequirements,
TimeToMarket timeToMarket,
OperationalMaturity operationalMaturity,
TechnicalDebt technicalDebt
) {
public ArchitectureRecommendation recommend() {
return switch (this) {
case var ctx when isStartupContext(ctx) ->
ArchitectureRecommendation.MODULAR_MONOLITH;
case var ctx when isScaleFirstContext(ctx) ->
ArchitectureRecommendation.MICROSERVICES;
case var ctx when isEventHeavyContext(ctx) ->
ArchitectureRecommendation.EVENT_DRIVEN;
case var ctx when isServerlessOptimal(ctx) ->
ArchitectureRecommendation.SERVERLESS;
default -> ArchitectureRecommendation.ANALYZE_FURTHER;
};
}
private boolean isStartupContext(ArchitectureContext ctx) {
return ctx.teamSize().ordinal() <= TeamSize.SMALL.ordinal() &&
ctx.timeToMarket() == TimeToMarket.CRITICAL &&
ctx.operationalMaturity().ordinal() <= OperationalMaturity.DEVELOPING.ordinal();
}
}
Monolithic Architecture: Done Right in 2025
The Modular Monolith Pattern
Contrary to popular belief, monoliths aren't dead—they've evolved. The modular monolith represents the best of both worlds: organizational simplicity with architectural clarity.
When to Choose Modular Monoliths:
- Teams < 20 developers
- Domain still evolving
- Fast time-to-market critical
- Limited operational expertise
- Data consistency is crucial
Implementation Strategy:
// Domain module structure
@Module
public class OrderModule {
@Bean
@ModuleScope
public OrderService orderService(
OrderRepository orderRepository,
PaymentService paymentService,
InventoryService inventoryService) {
return new OrderServiceImpl(orderRepository, paymentService, inventoryService);
}
@Bean
@ModuleScope
public OrderController orderController(OrderService orderService) {
return new OrderController(orderService);
}
}
// Module boundary enforcement
@ArchUnit
public class ModuleBoundaryTest {
@Test
public void orderModuleShouldNotDependOnUserModuleInternals() {
noClasses()
.that().resideInAPackage("..order..")
.should().dependOnClassesThat()
.resideInAPackage("..user.internal..")
.check(JavaClasses.importFrom("com.example"));
}
}
Key Benefits in 2025:
- Faster development cycles: Single codebase, unified testing
- ACID transactions: Natural data consistency
- Simplified operations: One deployment unit
- Easy refactoring: IDE-powered cross-module changes
Modern Modular Monolith Architecture:
// Shared kernel - common utilities and base classes
@SharedKernel
public class DomainEvent {
private final UUID eventId;
private final Instant occurredAt;
private final String eventType;
// Base event infrastructure
}
// Order Bounded Context
@BoundedContext("orders")
public class OrderAggregate {
@AggregateId
private OrderId id;
@DomainEvents
private List<DomainEvent> domainEvents = new ArrayList<>();
public void confirm() {
// Business logic
this.status = OrderStatus.CONFIRMED;
// Publish domain event within the monolith
domainEvents.add(new OrderConfirmedEvent(this.id));
}
}
// Event handling within the monolith
@Component
public class OrderEventHandler {
@EventListener
@Async
public void handleOrderConfirmed(OrderConfirmedEvent event) {
// Update read models, trigger workflows
inventoryService.allocateItems(event.getOrderId());
emailService.sendConfirmation(event.getOrderId());
}
}
Preparing for Future Decomposition
Smart monolith design anticipates future splitting:
// Interface segregation for future service extraction
public interface PaymentGateway {
PaymentResult processPayment(PaymentRequest request);
}
// Implementation stays within monolith initially
@Component
public class InternalPaymentGateway implements PaymentGateway {
private final PaymentRepository paymentRepository;
private final ExternalPaymentProvider externalProvider;
@Override
@Transactional
public PaymentResult processPayment(PaymentRequest request) {
// Internal implementation with database transactions
Payment payment = paymentRepository.save(Payment.create(request));
ExternalPaymentResponse response = externalProvider.charge(request);
payment.updateStatus(response.getStatus());
paymentRepository.save(payment);
return PaymentResult.from(payment);
}
}
// When ready to extract: replace with service client
@Component
@Profile("microservices")
public class PaymentServiceClient implements PaymentGateway {
private final PaymentServiceRestTemplate restTemplate;
@Override
public PaymentResult processPayment(PaymentRequest request) {
// Remote service call
return restTemplate.postForObject("/payments", request, PaymentResult.class);
}
}
Microservices Architecture: Battle-Tested Patterns
When Microservices Make Sense
After years of both successes and failures, we know microservices work well when:
- Organizational scale: Teams > 20 developers
- Domain maturity: Stable business capabilities
- Independent scaling: Different load characteristics
- Technology diversity: Different technical requirements
- Operational maturity: Strong DevOps culture
Modern Microservices Implementation
Service Design Principles:
// 1. Single Responsibility per Business Capability
@RestController
@RequestMapping("/api/inventory")
public class InventoryService {
// Only inventory-related operations
@GetMapping("/{productId}/availability")
public InventoryLevel checkAvailability(@PathVariable String productId) {
return inventoryRepository.findByProductId(productId)
.map(InventoryLevel::from)
.orElse(InventoryLevel.unavailable());
}
@PostMapping("/{productId}/reserve")
public ReservationResult reserve(@PathVariable String productId,
@RequestBody ReservationRequest request) {
return inventoryManager.reserve(productId, request.getQuantity());
}
}
// 2. Database per Service
@Entity
@Table(name = "inventory_items", schema = "inventory")
public class InventoryItem {
@Id
private String productId;
private int availableQuantity;
private int reservedQuantity;
// Only this service can modify inventory data
}
// 3. API-First Design with OpenAPI
@OpenAPIDefinition(
info = @Info(title = "Inventory Service", version = "1.0"),
servers = @Server(url = "/api/inventory")
)
public class InventoryServiceApplication {
// Service contract defined first
}
Inter-Service Communication Patterns:
// Synchronous communication with resilience
@Component
public class PaymentServiceClient {
private final WebClient webClient;
private final CircuitBreaker circuitBreaker;
@Retryable(value = {TransientException.class}, maxAttempts = 3)
@CircuitBreaker(name = "payment-service", fallbackMethod = "fallbackPayment")
public Mono<PaymentResult> processPayment(PaymentRequest request) {
return webClient
.post()
.uri("/payments")
.bodyValue(request)
.retrieve()
.bodyToMono(PaymentResult.class)
.timeout(Duration.ofSeconds(5));
}
public Mono<PaymentResult> fallbackPayment(PaymentRequest request, Exception ex) {
// Graceful degradation
return Mono.just(PaymentResult.deferred("Payment service unavailable"));
}
}
// Asynchronous communication via events
@KafkaListener(topics = "order-events")
public void handleOrderCreated(OrderCreatedEvent event) {
inventoryService.reserveItems(event.getOrderId(), event.getItems());
}
Data Management in Microservices:
// Saga pattern for distributed transactions
@Component
public class OrderProcessingSaga {
@SagaStart
public void processOrder(OrderCreatedEvent event) {
// Start saga
SagaTransaction saga = sagaManager.begin("order-processing", event.getOrderId());
// Step 1: Reserve inventory
saga.invoke(inventoryService::reserve, event.getItems())
.compensate(inventoryService::release, event.getItems());
// Step 2: Process payment
saga.invoke(paymentService::charge, event.getPayment())
.compensate(paymentService::refund, event.getPayment());
// Step 3: Confirm order
saga.invoke(orderService::confirm, event.getOrderId())
.compensate(orderService::cancel, event.getOrderId());
saga.execute();
}
}
// Event sourcing for audit and replay
@EventSourcingAggregate
public class Order {
@AggregateId
private OrderId id;
@CommandHandler
public void handle(CreateOrderCommand command) {
apply(new OrderCreatedEvent(command.getOrderId(), command.getCustomerId()));
}
@EventSourcingHandler
public void on(OrderCreatedEvent event) {
this.id = event.getOrderId();
this.customerId = event.getCustomerId();
this.status = OrderStatus.PENDING;
}
}
Service Mesh Integration
Modern microservices leverage service mesh for cross-cutting concerns:
# Istio service configuration
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
name: payment-service
spec:
http:
- match:
- uri:
prefix: "/payments"
route:
- destination:
host: payment-service
weight: 90
- destination:
host: payment-service-canary
weight: 10
timeout: 30s
retries:
attempts: 3
perTryTimeout: 10s
retryOn: 5xx,reset,connect-failure,refused-stream
Event-Driven Architecture: The Async Advantage
Event-driven architectures excel when you need loose coupling, scalability, and real-time responsiveness. In 2025, they're essential for modern systems.
Core Event Design Patterns:
// Domain events as first-class citizens
@DomainEvent
public record OrderConfirmedEvent(
@EventId UUID eventId,
@AggregateId OrderId orderId,
@CustomerId String customerId,
@EventTime Instant confirmedAt,
List<OrderItem> items,
Money totalAmount
) {
public static OrderConfirmedEvent create(Order order) {
return new OrderConfirmedEvent(
UUID.randomUUID(),
order.getId(),
order.getCustomerId(),
Instant.now(),
order.getItems(),
order.getTotalAmount()
);
}
}
// Event store abstraction
public interface EventStore {
void append(String streamId, List<DomainEvent> events);
Stream<DomainEvent> read(String streamId);
Stream<DomainEvent> readAll(Instant from, Instant to);
}
// Kafka-based implementation
@Component
public class KafkaEventStore implements EventStore {
private final KafkaTemplate<String, DomainEvent> kafkaTemplate;
@Override
public void append(String streamId, List<DomainEvent> events) {
events.forEach(event -> {
kafkaTemplate.send(getTopicName(event.getClass()), streamId, event);
});
}
private String getTopicName(Class<? extends DomainEvent> eventType) {
return CaseFormat.UPPER_CAMEL.to(CaseFormat.LOWER_HYPHEN,
eventType.getSimpleName().replace("Event", ""));
}
}
Event Processing Patterns:
// Event processor with exactly-once semantics
@KafkaListener(topics = "order-confirmed",
containerFactory = "exactlyOnceKafkaListenerContainerFactory")
public void processOrderConfirmed(OrderConfirmedEvent event) {
try {
// Idempotent processing
if (processedEventRepository.hasProcessed(event.eventId())) {
log.info("Event already processed: {}", event.eventId());
return;
}
// Business logic
fulfillmentService.startFulfillment(event.orderId());
loyaltyService.awardPoints(event.customerId(), event.totalAmount());
analyticsService.recordOrderConfirmation(event);
// Mark as processed
processedEventRepository.markProcessed(event.eventId());
} catch (Exception e) {
// Send to dead letter queue for manual investigation
deadLetterPublisher.publish(event, e);
throw e;
}
}
// CQRS with event projections
@EventHandler
public class OrderProjectionHandler {
private final OrderViewRepository orderViewRepository;
@EventHandler
public void on(OrderCreatedEvent event) {
OrderView view = OrderView.builder()
.orderId(event.orderId())
.customerId(event.customerId())
.status("PENDING")
.createdAt(event.eventTime())
.build();
orderViewRepository.save(view);
}
@EventHandler
public void on(OrderConfirmedEvent event) {
orderViewRepository.updateStatus(event.orderId(), "CONFIRMED");
}
}
Event Schema Evolution:
// Versioned events with backward compatibility
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "version")
@JsonSubTypes({
@JsonSubTypes.Type(value = OrderCreatedEventV1.class, name = "v1"),
@JsonSubTypes.Type(value = OrderCreatedEventV2.class, name = "v2")
})
public abstract class OrderCreatedEvent {
public abstract OrderId orderId();
public abstract String customerId();
}
public record OrderCreatedEventV1(
OrderId orderId,
String customerId,
Instant createdAt
) implements OrderCreatedEvent {
}
public record OrderCreatedEventV2(
OrderId orderId,
String customerId,
Instant createdAt,
String customerEmail, // New field
List<String> tags // New field
) implements OrderCreatedEvent {
}
// Event upcaster for handling old events
@Component
public class OrderEventUpcaster {
public OrderCreatedEventV2 upcast(OrderCreatedEventV1 oldEvent) {
return new OrderCreatedEventV2(
oldEvent.orderId(),
oldEvent.customerId(),
oldEvent.createdAt(),
lookupCustomerEmail(oldEvent.customerId()), // Backfill
Collections.emptyList() // Default value
);
}
}
Serverless Java: GraalVM Native Images
Serverless has evolved significantly for Java. With GraalVM native images, we can achieve cold start times under 100ms.
Native Image Optimization:
// Optimized for native compilation
@SpringBootApplication
@ImportAutoConfiguration(exclude = {
DataSourceAutoConfiguration.class,
HibernateJpaAutoConfiguration.class
})
public class ServerlessOrderProcessorApplication {
public static void main(String[] args) {
SpringApplication.run(ServerlessOrderProcessorApplication.class, args);
}
// Configure for native image
@Bean
@NativeHint(types = {OrderCreatedEvent.class, PaymentProcessedEvent.class})
public ObjectMapper objectMapper() {
return JsonMapper.builder()
.addModule(new JavaTimeModule())
.build();
}
}
// Serverless function handler
@Component
public class OrderEventHandler implements Function<APIGatewayProxyRequestEvent, APIGatewayProxyResponseEvent> {
private final OrderService orderService;
@Override
public APIGatewayProxyResponseEvent apply(APIGatewayProxyRequestEvent event) {
try {
OrderCreatedEvent orderEvent = objectMapper.readValue(
event.getBody(), OrderCreatedEvent.class);
ProcessingResult result = orderService.processOrder(orderEvent);
return APIGatewayProxyResponseEvent.builder()
.statusCode(200)
.body(objectMapper.writeValueAsString(result))
.build();
} catch (Exception e) {
return APIGatewayProxyResponseEvent.builder()
.statusCode(500)
.body("{\"error\":\"" + e.getMessage() + "\"}")
.build();
}
}
}
Native Image Build Configuration:
{
"bundles": [
{
"name": "order-processor",
"condition": {
"typeReachable": "com.example.OrderEventHandler"
}
}
],
"resources": {
"includes": [
{
"pattern": "\\Qapplication.properties\\E"
}
]
},
"reflection": [
{
"name": "com.example.OrderCreatedEvent",
"allDeclaredConstructors": true,
"allPublicConstructors": true,
"allDeclaredMethods": true,
"allPublicMethods": true
}
]
}
Hybrid Architectures: Best of All Worlds
Real-world systems often combine patterns strategically:
// Monolith with event-driven extensions
@Component
public class HybridOrderService {
// Core order logic stays in monolith
@Transactional
public Order createOrder(CreateOrderRequest request) {
Order order = Order.create(request);
orderRepository.save(order);
// Publish event for async processing
eventPublisher.publish(new OrderCreatedEvent(order));
return order;
}
}
// Serverless functions for specific workloads
@FunctionName("order-analytics")
public class OrderAnalyticsFunction {
@KafkaListener(topics = "order-events")
public void processForAnalytics(OrderEvent event) {
// Heavy analytical processing in serverless
AnalyticsData data = analyticsProcessor.process(event);
analyticsStore.store(data);
}
}
// Microservice for specialized capabilities
@RestController
public class RecommendationService {
// ML-based recommendations as separate service
@GetMapping("/recommendations/{customerId}")
public List<Product> getRecommendations(@PathVariable String customerId) {
return mlModel.generateRecommendations(customerId);
}
}
Architecture Evolution Strategy
Systems evolve. Here's how to plan for architectural transitions:
// Strangler Fig pattern for gradual migration
@Component
public class OrderServiceProxy {
@Value("${migration.percentage:0}")
private int migrationPercentage;
private final LegacyOrderService legacyService;
private final NewOrderService newService;
public Order createOrder(CreateOrderRequest request) {
if (shouldUseLegacy(request)) {
return legacyService.createOrder(request);
} else {
return newService.createOrder(request);
}
}
private boolean shouldUseLegacy(CreateOrderRequest request) {
// Gradual migration strategy
return hash(request.getCustomerId()) % 100 > migrationPercentage;
}
}
Conclusion: Choosing Your Path
The key insight from 2025: architecture patterns are not mutually exclusive. The most successful systems combine patterns strategically:
- Start with modular monoliths for speed and simplicity
- Extract microservices when team boundaries and scaling needs are clear
- Embrace events for decoupling and real-time requirements
- Use serverless for variable workloads and cost optimization
In Part 3, we'll dive deep into implementation details: designing robust APIs, managing data consistency, implementing security, and building observability into your chosen architecture.
Coming Next:
- API design and versioning strategies
- Data layer patterns and consistency models
- Security implementation across architectures
- Observability and monitoring patterns
This is Part 2 of "The Complete Guide to Modern Java Architecture." Continue with [Part 3: Implementation Deep Dives] for hands-on implementation strategies.
Download the companion code examples and architecture templates at: GitHub Repository