Published on

The Complete Guide to Modern Java Architecture - Part 2: Architecture Patterns

Authors

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