One class, one reason to change.
// BAD: PaymentService validates, saves, AND sends email
class PaymentService {
void process(Payment p) { validate(p); save(p); sendEmail(p); }
}
// GOOD: each class has one job
class PaymentValidator { void validate(Payment p) { ... } }
class PaymentRepository { void save(Payment p) { ... } }
class PaymentNotifier { void notify(Payment p) { ... } }
Open for extension, closed for modification. Add behaviour without changing existing code.
// BAD: adding a new payment type means editing this switch
double getFee(Payment p) {
switch (p.getType()) {
case WIRE: return 15.0;
case ACH: return 0.5;
}
}
// GOOD: add new FeeStrategy without touching existing code
interface FeeStrategy { double getFee(Payment p); }
class WireFeeStrategy implements FeeStrategy { ... }
class CryptoFeeStrategy implements FeeStrategy { ... } // new type — no changes elsewhere
A subtype must be substitutable for its parent without breaking the program.
// VIOLATION: ReadOnlyAccount extends Account but throws on withdraw()
// Callers who expect Account.withdraw() to work will break.
// Fix: don't extend — use a separate interface or composition.
Don't force clients to depend on methods they don't use.
// BAD: one fat interface
interface AccountOperations {
void deposit(); void withdraw(); void generateReport(); void auditLog();
}
// GOOD: split by client need
interface Transactional { void deposit(); void withdraw(); }
interface Reportable { void generateReport(); }
interface Auditable { void auditLog(); }
Depend on abstractions, not concretions. High-level modules shouldn't depend on low-level modules.
// BAD: PaymentService directly depends on EmailService (concrete)
class PaymentService {
private final EmailService email = new EmailService(); // hard-coded
}
// GOOD: depends on abstraction, injected by Spring
class PaymentService {
private final NotificationSender sender; // interface — swap email/SMS/push freely
public PaymentService(NotificationSender sender) { this.sender = sender; }
}
Define a family of algorithms, encapsulate each, and make them interchangeable at runtime. Eliminates if/else or switch chains based on type.
interface FeeStrategy {
BigDecimal calculate(Transaction txn);
}
class StandardFee implements FeeStrategy { ... } // 1.5%
class PremiumFee implements FeeStrategy { ... } // 0.5%
class InternationalFee implements FeeStrategy { ... } // 3% + FX markup
@Service
class TransactionService {
private final Map<CustomerTier, FeeStrategy> strategies;
public BigDecimal process(Transaction txn, CustomerTier tier) {
return strategies.get(tier).calculate(txn); // no if/else — just a map lookup
}
}
Defines an interface for creating one type of object. Subclasses decide which class to instantiate.
abstract class PaymentProcessorFactory {
public abstract PaymentProcessor create(); // subclass decides
}
class StripeProcessorFactory extends PaymentProcessorFactory {
public PaymentProcessor create() { return new StripeProcessor(apiKey); }
}
class PaymentProcessorFactory {
public static PaymentProcessor create(PaymentType type) {
return switch (type) {
case STRIPE -> new StripeProcessor(config);
case PAYPAL -> new PaypalProcessor(config);
case WIRE -> new WireTransferProcessor(config);
};
}
}
Creates families of related objects. Ensures objects from the same family are used together.
interface PaymentInfraFactory {
PaymentGateway gateway();
FraudChecker fraudChecker();
NotificationSender notifier();
}
class EUPaymentFactory implements PaymentInfraFactory { ... } // SEPA, EU fraud rules
class USPaymentFactory implements PaymentInfraFactory { ... } // ACH, US fraud rules
class SandboxPaymentFactory implements PaymentInfraFactory { ... } // mocks for testing
context.getBean(PaymentService.class) is a factory method call. Spring's DI container is an Abstract Factory that assembles entire object graphs.Subject notifies multiple observers when its state changes. Observers don't know about each other. Subject doesn't know observer details — loose coupling.
// 1. Define the event
public record OrderPlacedEvent(Order order) {}
// 2. Publish from the service
@Service
class OrderService {
@Autowired ApplicationEventPublisher publisher;
@Transactional
public void placeOrder(Order order) {
orderRepo.save(order);
publisher.publishEvent(new OrderPlacedEvent(order)); // same transaction!
}
}
// 3. Multiple independent listeners
@Component class NotificationListener {
@EventListener
public void onOrder(OrderPlacedEvent e) { notifier.send(e.order()); }
}
@Component class InventoryListener {
@TransactionalEventListener(phase = AFTER_COMMIT) // fires only if TX commits
public void onOrder(OrderPlacedEvent e) { inventory.reserve(e.order()); }
}
@EventListener: fires immediately, inside the same transaction. If transaction rolls back, the listener has already run.@TransactionalEventListener(AFTER_COMMIT): fires only after the transaction successfully commits. Safe for sending emails, calling external APIs, publishing to Kafka.Same pattern, different scope. In-process events → Spring ApplicationEvents. Cross-service events → Kafka topics. Kafka is the Observer pattern at microservice scale: producers publish events, multiple consumer groups subscribe independently.
Attach additional responsibilities to an object dynamically by wrapping it. Alternative to subclassing — behaviours compose rather than inherit.
interface OrderProcessor {
OrderResult process(Order order);
}
class CoreOrderProcessor implements OrderProcessor {
public OrderResult process(Order order) { /* core logic */ }
}
class LoggingDecorator implements OrderProcessor {
private final OrderProcessor delegate;
public OrderResult process(Order order) {
log.info("Processing order {}", order.getId());
OrderResult result = delegate.process(order);
log.info("Done in {}ms", elapsed);
return result;
}
}
class FraudCheckDecorator implements OrderProcessor {
public OrderResult process(Order order) {
fraudService.check(order); // throws if fraud detected
return delegate.process(order);
}
}
// Compose: fraud check → logging → core
OrderProcessor processor = new LoggingDecorator(
new FraudCheckDecorator(
new CoreOrderProcessor()));
LoggedFraudCheckedOrderProcessor — class explosion with many combinations.new BufferedReader(new FileReader(...))) and Spring's @Cacheable, @Transactional, @Async are all decorators via AOP.Constructor with many parameters is error-prone (easy to swap args of same type), hard to read, and forces you to pass nulls for optional fields.
// HARD to read: what do these booleans mean?
new Order("ORD-1", customerId, items, address, true, false, null, priority);
Order order = Order.builder()
.id("ORD-1")
.customerId(customerId)
.items(items)
.shippingAddress(address)
.expressDelivery(true)
.priority(Priority.HIGH)
.build(); // validate required fields here, throw if missing
@Builder generates the builder automatically. @Builder(toBuilder = true) lets you copy and modify an existing instance — useful for immutable update patterns.build() method: check nulls, throw IllegalStateException with a clear message. This ensures you can never construct an invalid object — fail fast at the boundary.Core business logic sits at the centre, isolated from external concerns (DB, HTTP, Kafka, external APIs). The domain defines ports (interfaces for what it needs). External systems plug in via adapters (implementations of those interfaces).
src/
domain/
model/ Order, Customer, Payment // pure business objects
port/
in/ OrderService (interface) // what drives the domain
out/ OrderRepository, PaymentGateway // what domain needs
service/ OrderServiceImpl // business logic — no frameworks
adapter/
in/
web/ OrderController // REST → calls domain port
kafka/ OrderEventConsumer // Kafka → calls domain port
out/
persistence/ JpaOrderRepository // implements domain's out port
external/ StripePaymentAdapter // implements PaymentGateway
| Layered | Hexagonal | |
|---|---|---|
| Dependencies | Top-down (Controller → Service → Repo) | All point inward (Adapters → Domain) |
| DB coupling | JPA leaks into service layer | Domain knows nothing about JPA |
| Testability | Hard without Spring context | Domain unit-testable in isolation |
Separate the write model from the read model.
// Write side: normalised, ACID, strongly consistent
orderCommandService.placeOrder(command); // writes to orders DB
// Read side: denormalised, optimised for queries, eventually consistent
orderQueryService.getOrderSummary(id); // reads from read-optimised view
Sync between them via events (Kafka). Write side publishes OrderPlaced → read side updates its projection.
Store all state changes as immutable events. Current state = replay all events from the beginning (or from a snapshot).
// Account event store:
AccountCreated { accountId, ownerId, timestamp }
MoneyDeposited { accountId, amount, timestamp }
MoneyWithdrawn { accountId, amount, timestamp }
AccountFrozen { accountId, reason, timestamp }
// Current balance = sum of all deposits - sum of all withdrawals
// Full audit trail is free — it's the source of truth.