Why Most Backend Architecture Patterns Are Over‑Engineered
A code‑review anecdote shows that developers often apply heavyweight patterns like Abstract Factory, Event Sourcing, CQRS, and DDD to simple payment processing, leading to unnecessary complexity; the article explains why this happens, which patterns truly belong in micro‑service backends, and offers practical, lightweight alternatives together with concrete code examples and review guidelines.
Pattern trap in modern backend development
During a code review a colleague built an order‑processing service with Strategy, Factory and Abstract Factory to support two payment methods, claiming the design would easily scale to twenty. The same logic can be expressed with a simple if‑else or an enum‑based strategy.
Why developers fall into the pattern trap
Interview pressure : interviewers often ask for high‑concurrency designs, prompting candidates to memorize and showcase classic patterns.
Technical vanity : naming DDD, Event Sourcing or CQRS is perceived as a seniority badge, even when the problem does not require them.
Patterns that often do not fit micro‑service, cloud‑native or serverless contexts
Abstract Factory
Classic usage : provides an interface for creating related objects without specifying concrete classes.
Issue : In a micro‑service each service should own its data and logic; a cross‑service factory creates a distributed monolith.
Recommended practice : Keep factories inside the service. For cross‑service data use API calls or events.
@Service
public class OrderService {
private final PaymentClient paymentClient;
public Order createOrder(CreateOrderRequest request) {
Order order = new Order();
order.setItems(request.getItems());
order.setTotal(request.getTotal());
return orderRepository.save(order);
}
}Event Sourcing
Classic usage : stores every state‑changing event instead of the current state.
Issue : An e‑commerce order system rarely needs full replay; a version column and audit log are sufficient.
Recommended practice : Use only when a complete audit trail or legal compliance is required.
@Entity
public class Order {
@Id private Long id;
private String status;
private BigDecimal amount;
@CreatedDate private LocalDateTime createdAt;
@LastModifiedDate private LocalDateTime updatedAt;
public void cancel() {
this.status = "CANCELLED";
this.updatedAt = LocalDateTime.now();
}
}CQRS (Command‑Query Responsibility Segregation)
Classic usage : separates read and write models, often with different databases.
Issue : Most business workloads have a read/write ratio of 9:1 or 99:1. Introducing separate models for the 1 % write side adds unnecessary complexity.
Recommended practice : Start with a simple cache‑through read path and a write‑through database; adopt CQRS only after a proven performance bottleneck.
@RestController
public class OrderController {
@GetMapping("/orders/{id}")
public Order getOrder(@PathVariable Long id) {
return cacheService.getOrLoad("order:" + id,
() -> orderRepository.findById(id).orElseThrow());
}
@PostMapping("/orders")
public Order createOrder(@RequestBody CreateOrderRequest request) {
Order order = orderService.createOrder(request);
cacheService.evict("order:" + order.getId());
return order;
}
}Over‑engineered DDD
Classic usage : aggregates, value objects, domain services, repository interfaces, anti‑corruption layers.
Issue : For a simple user‑management module, creating UserAggregateRoot, AddressValueObject, UserRepositoryInterface, etc., adds no tangible value.
Recommended practice : Apply DDD ideas (focus on domain logic) without forcing a rigid layered structure.
@Entity
@Data
@NoArgsConstructor
@AllArgsConstructor
public class User {
@Id private Long id;
private String username;
private String email;
@Embedded private Address address;
public boolean canResetPassword() {
return !isLocked() && isActive();
}
public void changeEmail(String newEmail) {
validateEmail(newEmail);
this.email = newEmail;
this.emailVerified = false;
}
}What the useful 10 % looks like
Clear dependency direction
High‑level modules should depend on abstractions, not concrete low‑level modules. In Spring Boot define service interfaces and inject implementations, but avoid abstraction for its own sake.
public interface PaymentService {
PaymentResult pay(PaymentRequest request);
RefundResult refund(RefundRequest request);
}
@Service
public class AlipayService implements PaymentService { /* ... */ }
public class OrderService {
private final PaymentService paymentService;
public OrderService(PaymentService paymentService) {
this.paymentService = paymentService;
}
}Simplified Strategy
Replace verbose if‑else chains with a map of processors or Spring’s @Conditional beans.
public class PaymentStrategy {
private final Map<String, PaymentProcessor> processors;
public PaymentStrategy(List<PaymentProcessor> list) {
processors = list.stream()
.collect(Collectors.toMap(PaymentProcessor::getType, Function.identity()));
}
public PaymentResult process(String type, PaymentRequest request) {
PaymentProcessor processor = processors.get(type);
if (processor == null) {
throw new IllegalArgumentException("Unsupported payment type: " + type);
}
return processor.process(request);
}
}Adapter pattern in practice
Integrate third‑party SDKs while keeping the codebase clean.
@Component
public class WechatPayAdapter implements PaymentProcessor {
private final WechatPayClient wechatPayClient;
@Override
public PaymentResult process(PaymentRequest request) {
WechatPayRequest wechatRequest = convertToWechatRequest(request);
WechatPayResponse response = wechatPayClient.pay(wechatRequest);
return convertToPaymentResult(response);
}
}Observer pattern for event‑driven flow
Decouple business logic by publishing domain events.
@Service
public class OrderService {
private final ApplicationEventPublisher eventPublisher;
public Order createOrder(CreateOrderRequest request) {
Order order = /* create order */;
eventPublisher.publishEvent(new OrderCreatedEvent(this, order));
return order;
}
}
@Component
public class OrderEventListener {
@EventListener
public void handleOrderCreated(OrderCreatedEvent event) {
emailService.sendOrderConfirmation(event.getOrder());
inventoryService.updateStock(event.getOrder());
log.info("Order created: {}", event.getOrder().getId());
}
}Principles applied during code review
Keep it simple : if an if‑else solves the problem, do not introduce Strategy; if a direct call works, do not add an Adapter.
Program for real change : design for actual business evolution, not imagined scenarios.
Practical layering : a three‑layer architecture is sufficient; avoid extra Manager/Processor/Handler layers without clear benefit.
Match tech stack to business scale : do not use a system built for ten‑million‑DAU when the service handles a thousand‑DAU.
Code is for humans : ensure the next developer can understand and modify the code quickly.
When complex architecture is justified
Actual performance bottlenecks (e.g., database saturation) that require sharding, CQRS, or other scaling techniques.
Business domains that are genuinely complex, such as inventory management or financial transaction processing, where DDD adds clarity.
Team size exceeding twenty developers, where clear architectural boundaries improve collaboration.
Requirements for ultra‑high availability (e.g., 99.99 % SLA).
Architectural complexity should be proportional to business complexity, not to a developer’s desire to showcase patterns.
java1234
Former senior programmer at a Fortune Global 500 company, dedicated to sharing Java expertise. Visit Feng's site: Java Knowledge Sharing, www.java1234.com
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
