The Design Patterns I Actually Use in Java (And a Few I've Mostly Given Up On)
I've been writing Java professionally for about 25 years now, and I can still remember the week I first read the Gang of Four book. I was a junior dev and I was convincing myself that learning all 23 patterns would make me a better engineer. And it did, kind of but it also turned me into someone who seriously considered writing a Visitor pattern for a problem that needed a HashMap . Not a great phase. The honest truth is that a handful of patterns show up constantly in real codebases, and the rest are solutions waiting for a problem that rarely arrives. What I want to share here are the ones I actually reach for, why I reach for them, and a few concrete examples that aren't just the usual "Shape and Circle" tutorial stuff. Builder: Honestly My Most-Used Pattern When you're constructing objects with more than three or four fields, especially optional ones, constructors become a nightmare fast. I hit this hard last spring when we were modeling API request objects for a third-party logistics integration. The request had about fifteen fields: some required, some conditional on other fields, and a few that only mattered for international shipments. Before Builder, we had this constructor call that I was genuinely embarrassed to commit: ShipmentRequest request = new ShipmentRequest( "US", "CA", null, true, false, null, "GROUND", 5, null, "B2B", null, null, true, false, null ); Nobody could tell what true and false meant at the call site without counting arguments. Don't ask me how long it took to debug the first time we got the boolean order wrong. After refactoring to Builder: public class ShipmentRequest { private final String originCountry; private final String destinationState; private final String serviceLevel; private final boolean signatureRequired; private final boolean internationalShipment; private final int weightLbs; private ShipmentRequest(Builder builder) { this.originCountry = builder.originCountry; this.destinationState = builder.destinationState; this.serviceLevel = builder.serviceLevel; this.signatureRequired = builder.signatureRequired; this.internationalShipment = builder.internationalShipment; this.weightLbs = builder.weightLbs; } public static class Builder { private final String originCountry; private final String destinationState; private String serviceLevel = "GROUND"; private boolean signatureRequired = false; private boolean internationalShipment = false; private int weightLbs; public Builder(String originCountry, String destinationState) { this.originCountry = originCountry; this.destinationState = destinationState; } public Builder serviceLevel(String serviceLevel) { this.serviceLevel = serviceLevel; return this; } public Builder signatureRequired(boolean signatureRequired) { this.signatureRequired = signatureRequired; return this; } public Builder internationalShipment(boolean internationalShipment) { this.internationalShipment = internationalShipment; return this; } public Builder weightLbs(int weightLbs) { this.weightLbs = weightLbs; return this; } public ShipmentRequest build() { if (weightLbs <= 0) { throw new IllegalStateException("Weight must be positive"); } return new ShipmentRequest(this); } } } The call site becomes readable immediately: ShipmentRequest request = new ShipmentRequest.Builder("US", "CA") .serviceLevel("EXPRESS") .signatureRequired(true) .weightLbs(12) .build(); If you're using Lombok (and I do, on every project now), @Builder gives you most of this for free. But I still write it by hand when I need custom validation in build() , or when I want to be explicit about which fields are required vs. optional. The annotation is convenient; it's just not always enough. Strategy: The One That Replaced My Giant Switch Statements I used to write a lot of switch statements. The kind where a teammate opens the file and quietly closes it again. Strategy is basically the antidote. Define a family of algorithms behind a common interface, and swap them in at runtime without the calling code caring which one it's using. We used this during the billing service rewrite last fall. We had different tax calculation rules for different US states, and originally it was one massive switch statement keyed on state code. Adding a new state meant touching the same file every single time. public interface TaxStrategy { double calculate(double subtotal); } public class CaliforniaTaxStrategy implements TaxStrategy { @Override public double calculate(double subtotal) { return subtotal * 0.0725; } } public class TexasTaxStrategy implements TaxStrategy { @Override public double calculate(double subtotal) { return subtotal * 0.0625; } } public class DefaultTaxStrategy implements TaxStrategy { @Override public double calculate(double subtotal) { return 0.0; } } Then a simple registry to look up the right one: public class TaxStrategyRegistry { private static final Map<String, TaxStrategy> STRATEGIES = Map.of( "CA", new CaliforniaTaxStrategy(), "TX", new TexasTaxStrategy() ); public static TaxStrategy forState(String stateCode) { return STRATEGIES.getOrDefault(stateCode, new DefaultTaxStrategy()); } } Clean call site: TaxStrategy strategy = TaxStrategyRegistry.forState(order.getStateCode()); double tax = strategy.calculate(order.getSubtotal()); Adding Oregon now means writing a new class and dropping one line into the map. Nobody touches existing logic. That's the whole point. Singleton: Useful, But I've Learned to Be Careful Singletons have a bad reputation, and some of it is deserved. Overused singletons turn into global state, and global state is exactly the thing that makes integration tests a miserable experience. But there are genuinely good use cases: connection pools, configuration managers, thread-safe caches. The version I trust in Java is the enum-based Singleton. It handles serialization and reflection attacks automatically, which are two things the classic "private constructor + static instance" approach does not handle well. public enum AppConfig { INSTANCE; private final Properties props; AppConfig() { props = new Properties(); try (InputStream in = getClass().getResourceAsStream("/app.properties")) { props.load(in); } catch (IOException e) { throw new ExceptionInInitializerError("Failed to load app config"); } } public String get(String key) { return props.getProperty(key); } } Usage is just AppConfig.INSTANCE.get("api.timeout") . Simple. Honestly though, if you're in a Spring Boot service, you rarely need to write explicit Singletons at all. The container manages bean lifecycle, and @Component with the default scope is effectively singleton-scoped. I mostly write explicit Singletons for non-Spring utilities or small CLI tools where pulling in a framework would be overkill. Observer: Where It Shines and Where It Gets Messy Observer is the pattern behind every event system you've ever used. One object changes state, other objects get notified. Decoupled. Clean. In theory. In practice, if you're not careful, you end up with a web of listeners that's nearly impossible to trace. I've been there. On a Spring Boot service that processed order events a couple of years back, we had so many @EventListener methods scattered across packages that understanding what actually happened after an order was placed required grepping the entire codebase. Not a great experience when you're trying to diagnose a prod...