Techniques That Make Enterprise Apps Maintainable: The Most Used Design Patterns in Java Web Development
In most enterprise projects, nobody starts the day saying, “Today I will apply the Strategy pattern with great elegance.” Instead, you open your IDE, fix bugs, wire APIs, and ship features. Yet, beneath that daily chaos, design patterns are doing...

I am Tuanh.net. As of 2024, I have accumulated 8 years of experience in backend programming. I am delighted to connect and share my knowledge with everyone.
1. How Design Patterns Quietly Shape Modern Enterprise Java
1.1 From “just make it work” to “make it scale and survive”
1.2 Frameworks are design patterns packaged as tools
1.3 Design patterns and real-world trust in enterprise systems
2. Dependency Injection and Inversion of Control: The Invisible Backbone
new, a container like the Spring IoC container builds objects for you and injects them where needed. This sounds simple, but it fundamentally changes your architecture. Classes no longer “hunt” for what they need; instead, they declare what they need, and the framework provides it. This is the backbone of testability, flexibility, and modularity in modern Java backend development.
2.1 A concrete Java example of Dependency Injection
new EmailNotificationService(). With DI, you rely on interfaces and let Spring choose and inject the right implementation.
public interface NotificationService {
void sendNotification(String to, String message);
}
@Service
public class EmailNotificationService implements NotificationService {
@Override
public void sendNotification(String to, String message) {
// Imagine real email sending here
System.out.println("Sending EMAIL to " + to + ": " + message);
}
}
@Service
public class SmsNotificationService implements NotificationService {
@Override
public void sendNotification(String to, String message) {
// Imagine real SMS sending here
System.out.println("Sending SMS to " + to + ": " + message);
}
}
@RestController
@RequestMapping("/orders")
public class OrderController {
private final NotificationService notificationService;
public OrderController(NotificationService notificationService) {
this.notificationService = notificationService;
}
@PostMapping
public ResponseEntity<string> createOrder(@RequestBody CreateOrderRequest request) {
// 1. validate input
// 2. create order in database
// 3. send notification
notificationService.sendNotification(request.getCustomerEmail(),
"Your order has been created!");
return ResponseEntity.ok("Order created");
}
}
OrderController never calls new EmailNotificationService(). It only declares a dependency on NotificationService. The Spring container decides which NotificationService implementation to inject, based on configuration or annotations. In tests, you can inject a fake implementation that just records calls instead of sending real emails. This separation of concerns is the core value of the DI pattern.
2.2 Why this pattern dominates enterprise Java
NotificationService without touching the controller. You might also configure which implementation is used by default through profiles or configuration classes. Resources like dependency injection diagrams or IoC container schematics, such as those in Java Spring architecture overviews (for example, search images on “Spring IoC container diagram”), visually show a container in the center and application components around it. These images help you mentally model how the framework does the wiring for you.
2.3 Subtle pitfalls of DI when misused
3. MVC and Layered Architecture: Turning HTTP Chaos into Structure
3.1 A typical Java controller–service–repository flow
EntityManager. Instead, you funnel the work through layers.
@Entity
@Table(name = "customers")
public class Customer {
@Id
private Long id;
private String fullName;
private String email;
// getters and setters
}
public interface CustomerRepository extends JpaRepository<customer, long=""> {
// Spring Data JPA gives us implementation at runtime
}
@Service
public class CustomerService {
private final CustomerRepository repository;
public CustomerService(CustomerRepository repository) {
this.repository = repository;
}
public Customer getCustomer(Long id) {
return repository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Customer not found: " + id));
}
}
@RestController
@RequestMapping("/customers")
public class CustomerController {
private final CustomerService service;
public CustomerController(CustomerService service) {
this.service = service;
}
@GetMapping("/{id}")
public ResponseEntity<customer> getCustomer(@PathVariable Long id) {
Customer customer = service.getCustomer(id);
return ResponseEntity.ok(customer);
}
}
JpaRepository is an implementation of the Repository pattern, abstracting away the details of SQL. The CustomerService embodies a Service Layer pattern: it contains business rules around customers. The controller implements the Controller part of MVC, making sure that HTTP requests are understood and HTTP responses are formed correctly.
3.2 Why layered patterns are essential at scale
4. Strategy Pattern: Making Business Rules Pluggable
4.1 A Java example: discount strategies
if (type == "PERCENTAGE") ... everywhere is painful. Instead, you model each discount rule as a strategy.
public interface DiscountStrategy {
double applyDiscount(double originalPrice);
}
@Component("noDiscount")
public class NoDiscountStrategy implements DiscountStrategy {
@Override
public double applyDiscount(double originalPrice) {
return originalPrice;
}
}
@Component("percentageDiscount")
public class PercentageDiscountStrategy implements DiscountStrategy {
private final double percentage;
public PercentageDiscountStrategy() {
this.percentage = 0.10; // 10%, could come from config
}
@Override
public double applyDiscount(double originalPrice) {
return originalPrice * (1 - percentage);
}
}
@Component("fixedAmountDiscount")
public class FixedAmountDiscountStrategy implements DiscountStrategy {
private final double amount;
public FixedAmountDiscountStrategy() {
this.amount = 50.0; // could come from config or database
}
@Override
public double applyDiscount(double originalPrice) {
return Math.max(0, originalPrice - amount);
}
}
@Service
public class PricingService {
private final Map<string, discountstrategy=""> strategies;
public PricingService(Map<string, discountstrategy=""> strategies) {
this.strategies = strategies;
}
public double calculatePrice(double originalPrice, String discountType) {
DiscountStrategy strategy = strategies.getOrDefault(discountType, strategies.get("noDiscount"));
return strategy.applyDiscount(originalPrice);
}
}
Map
in the PricingService. Spring automatically injects all beans implementing DiscountStrategy into that map, keyed by bean name. This makes your discount logic incredibly flexible: adding a new discount type means creating a new class and a new bean name. The core pricing logic remains untouched, which is the heart of Strategy.
4.2 Strategy in real enterprise scenarios
PricingService and the concrete discount classes, the pattern stops being abstract and becomes a very practical tool.
5. Template Method Pattern: Defining Stable Workflows with Flexible Steps
JdbcTemplate or base controller classes that provide a fixed structure while giving you hooks to customize behavior.
5.1 A Java example: processing payments with a template
public abstract class PaymentProcessorTemplate {
public final void processPayment(String customerId, double amount) {
validate(customerId, amount);
reserveFunds(customerId, amount);
finalizePayment(customerId, amount);
sendReceipt(customerId, amount);
}
protected abstract void validate(String customerId, double amount);
protected abstract void reserveFunds(String customerId, double amount);
protected abstract void finalizePayment(String customerId, double amount);
protected void sendReceipt(String customerId, double amount) {
System.out.println("Sending generic receipt to " + customerId + " for " + amount);
}
}
public class CreditCardPaymentProcessor extends PaymentProcessorTemplate {
@Override
protected void validate(String customerId, double amount) {
System.out.println("Validating credit card for " + customerId);
}
@Override
protected void reserveFunds(String customerId, double amount) {
System.out.println("Reserving " + amount + " on credit card");
}
@Override
protected void finalizePayment(String customerId, double amount) {
System.out.println("Capturing " + amount + " from credit card");
}
}
public class BankTransferPaymentProcessor extends PaymentProcessorTemplate {
@Override
protected void validate(String customerId, double amount) {
System.out.println("Validating bank account for " + customerId);
}
@Override
protected void reserveFunds(String customerId, double amount) {
System.out.println("No reservation, waiting for bank transfer");
}
@Override
protected void finalizePayment(String customerId, double amount) {
System.out.println("Confirming incoming bank transfer of " + amount);
}
}
processPayment is declared final to prevent subclasses from changing the workflow order. They can only customize each step. This is exactly what Template Method is about: fixing the algorithm structure while letting subclasses vary parts of the process.
5.2 Where Template Method shines in enterprise apps
6. Singleton and Factory: Managing Shared Resources Without Losing Your Mind
6.1 A simple factory for HTTP clients in Java
public interface HttpClient {
void get(String url);
}
public class SimpleHttpClient implements HttpClient {
@Override
public void get(String url) {
System.out.println("Calling URL: " + url);
}
}
public class LoggingHttpClient implements HttpClient {
private final HttpClient delegate;
public LoggingHttpClient(HttpClient delegate) {
this.delegate = delegate;
}
@Override
public void get(String url) {
System.out.println("LOG: about to call " + url);
delegate.get(url);
}
}
public class HttpClientFactory {
private static final HttpClientFactory INSTANCE = new HttpClientFactory();
private HttpClientFactory() {
}
public static HttpClientFactory getInstance() {
return INSTANCE;
}
public HttpClient createClient(boolean enableLogging) {
HttpClient baseClient = new SimpleHttpClient();
if (enableLogging) {
return new LoggingHttpClient(baseClient);
}
return baseClient;
}
}
HttpClientFactory is a classic Singleton with a static getInstance method. In a pure Spring application, you would usually avoid manual Singletons and instead declare the factory as a @Component with singleton scope. Yet the pattern idea remains: centralize creation logic, hide complexity, and make it easier to change how you build objects later.
6.2 Visualizing factories and singletons
HttpClientFactory example. In enterprise apps, this style of pattern is useful whenever object creation becomes non-trivial: creating database connections, clients to external services, configuration objects, or even complex domain aggregates.
7. Observer and Event-Driven Patterns: Decoupling Features Through Events
7.1 A Java example using Spring events
ApplicationEventPublisher and @EventListener provide a ready-made Observer mechanism.
public class OrderCreatedEvent {
private final Long orderId;
public OrderCreatedEvent(Long orderId) {
this.orderId = orderId;
}
public Long getOrderId() {
return orderId;
}
}
@Service
public class OrderService {
private final ApplicationEventPublisher publisher;
public OrderService(ApplicationEventPublisher publisher) {
this.publisher = publisher;
}
public void createOrder(Long customerId) {
// 1. save order to database
Long orderId = 123L; // pretend this comes from DB
// 2. publish event
publisher.publishEvent(new OrderCreatedEvent(orderId));
}
}
@Component
public class SendOrderEmailListener {
@EventListener
public void handleOrderCreated(OrderCreatedEvent event) {
System.out.println("Sending email for order " + event.getOrderId());
}
}
@Component
public class UpdateAnalyticsDashboardListener {
@EventListener
public void updateDashboard(OrderCreatedEvent event) {
System.out.println("Updating analytics for order " + event.getOrderId());
}
}
OrderService only knows that it publishes an OrderCreatedEvent. It has no idea who will react to that event. Email sending and analytics updates are separate observers. This is a clean implementation of the Observer pattern, letting you plug in new listeners over time without touching the order creation logic.
7.2 Observer patterns and event diagrams
Subject with references to multiple Observers. When the subject changes state, it notifies observers. In Spring’s event mechanism, ApplicationEventPublisher plays the subject role, and beans with @EventListener act as observers. In enterprise environments, this idea scales further into message queues, domain events, and microservices talking via Kafka or RabbitMQ. The underlying pattern is the same: decouple “what happened” from “what to do about it.”
8. Techniques to Choose and Combine Patterns Wisely in Enterprise Projects
8.1 Patterns as a shared language for teams and reviews
8.2 Balancing simplicity and extensibility
Read more at : Techniques That Make Enterprise Apps Maintainable: The Most Used Design Patterns in Java Web Development





