SOLID Principles — Practical Demo
Hands-on examples for SOLID Principles. We'll build a customer notification system, introducing one SOLID violation at a time, then fixing it.
Before running these examples, make sure you understand the SOLID Principles — particularly what "reason to change" means for SRP, and how DIP differs from always adding an interface.
Scenario: Customer Notification System
We're building a service that registers new users and sends them a welcome notification. We'll start with a naive implementation and refactor it through each SOLID principle.
Example 1: SRP Violation — One Class Doing Too Much
The initial UserRegistrationService handles persistence, validation, and email in one class.
// ❌ Three reasons to change: database schema, validation rules, email provider
public class UserRegistrationService {
public void register(String email, String password) {
// Reason 1: validation logic
if (email == null || !email.contains("@")) { // {6}
throw new IllegalArgumentException("Invalid email");
}
// Reason 2: persistence logic
String sql = "INSERT INTO users (email, password) VALUES (?, ?)"; // {10}
jdbcTemplate.update(sql, email, hashPassword(password));
// Reason 3: email sending
smtpClient.send(email, "Welcome!", "Thanks for signing up"); // {15}
}
}
Problem: A change to the email provider (e.g., switching from SMTP to SendGrid) requires modifying the same class that owns the database logic. A change to password hashing requires touching the same class that owns email templates.
Each "section" in this method is a different reason to change. SRP says: split these into separate classes.
Example 2: SRP Fix — Separate the Responsibilities
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final UserValidator validator; // {5} ← validation concern
private final UserNotificationService notificationService; // {5} ← notification concern
public User register(RegisterRequest req) {
validator.validate(req); // {9} ← delegate to specialist
User user = userRepository.save(User.from(req));
notificationService.onUserRegistered(user); // ← notification is decoupled
return user;
}
}
@Component
public class UserValidator {
public void validate(RegisterRequest req) {
if (req.email() == null || !req.email().contains("@")) {
throw new ValidationException("Invalid email format");
}
if (req.password() == null || req.password().length() < 8) {
throw new ValidationException("Password must be at least 8 characters");
}
}
}
@Service
@RequiredArgsConstructor
public class UserNotificationService {
private final EmailSender emailSender;
public void onUserRegistered(User user) {
emailSender.send(user.getEmail(),
"Welcome to our platform!",
"Hi " + user.getName() + ", your account is ready.");
}
}
Result: Changing the email provider means editing only EmailSender. Changing validation rules means editing only UserValidator. UserService never changes for those reasons.
Example 3: OCP — Adding a New Notification Channel Without Modifying Existing Code
The notification system currently only sends email. We need to add SMS and push notifications.
// ✅ OCP: Define the abstraction once
public interface NotificationChannel {
void send(User user, String subject, String message);
String channelType(); // ← used for conditional logic at configuration time
}
@Component
public class EmailNotificationChannel implements NotificationChannel {
private final EmailSender emailSender;
@Override
public void send(User user, String subject, String message) {
emailSender.send(user.getEmail(), subject, message);
}
@Override
public String channelType() { return "EMAIL"; }
}
// ✅ Adding SMS: new class added, NO existing code modified
@Component
public class SmsNotificationChannel implements NotificationChannel {
private final SmsGateway smsGateway;
@Override
public void send(User user, String subject, String message) {
smsGateway.sendSms(user.getPhoneNumber(), message);
}
@Override
public String channelType() { return "SMS"; }
}
@Service
public class UserNotificationService {
private final List<NotificationChannel> channels; // {5} ← Spring injects all implementations
public UserNotificationService(List<NotificationChannel> channels) {
this.channels = channels;
}
public void onUserRegistered(User user) {
channels.stream()
.filter(ch -> user.getPreferences().allows(ch.channelType())) // ← user-controlled
.forEach(ch -> ch.send(user, "Welcome!", buildMessage(user))); // {9} ← OCP: add channel by adding a class
}
}
Spring's List<NotificationChannel> injection means adding a new channel is purely additive — create the class, annotate it with @Component, and Spring auto-discovers it. UserNotificationService never changes.
Example 4: DIP — Depending on Abstractions
Demonstrating that UserService depends on the UserRepository interface, not the JPA implementation.
// ✅ Abstraction — UserService's contract with the persistence layer
public interface UserRepository {
User save(User user);
Optional<User> findByEmail(String email);
}
// ✅ Low-level module — implements the contract
@Repository
public interface JpaUserRepository extends JpaRepository<User, Long>, UserRepository {
// Spring Data provides the implementation
}
// ✅ Test-time alternative — same interface, in-memory storage
public class InMemoryUserRepository implements UserRepository {
private final Map<Long, User> store = new HashMap<>();
private Long nextId = 1L;
@Override
public User save(User user) {
user.setId(nextId++);
store.put(user.getId(), user);
return user;
}
@Override
public Optional<User> findByEmail(String email) {
return store.values().stream()
.filter(u -> u.getEmail().equals(email))
.findFirst();
}
}
// ✅ Test with in-memory repo — no Spring context, no database
class UserServiceTest {
@Test
void register_shouldPersistAndNotify() {
UserRepository repo = new InMemoryUserRepository(); // {5} ← swap implementation for test
UserNotificationService notifier = mock(UserNotificationService.class);
UserService svc = new UserService(repo, new UserValidator(), notifier);
User result = svc.register(new RegisterRequest("user@example.com", "password123", "Alice"));
assertThat(result.getId()).isNotNull();
verify(notifier).onUserRegistered(result);
}
}
Expected Output:
Test passed — UserService wired with InMemoryUserRepository, no DB required.
DIP makes unit testing trivial — you can plug in a test double (InMemoryUserRepository) because UserService depends on the interface, not the JPA implementation.
Example 5: Full Wired Spring Boot Application
Showing how Spring Boot wires all the SOLID components together with constructor injection:
spring:
datasource:
url: jdbc:h2:mem:testdb
driver-class-name: org.h2.Driver
jpa:
hibernate:
ddl-auto: create-drop
show-sql: false
notification:
channels:
- EMAIL
- SMS
// ✅ Spring wires everything — UserService never touches new keyword for its dependencies
@SpringBootTest
class UserRegistrationEndToEnd {
@Autowired
UserService userService;
@MockBean
EmailSender emailSender; // ← mock the actual sender in tests
@Test
void registerUser_triggersEmailNotification() {
userService.register(new RegisterRequest("bob@example.com", "Secure123!", "Bob"));
verify(emailSender, times(1)).send(
eq("bob@example.com"),
eq("Welcome to our platform!"),
contains("Bob")
);
}
}
Exercises
Try these on your own to solidify understanding:
- Easy: Add a
PushNotificationChannelthat logs the notification to the console. Verify it's picked up byUserNotificationServicewithout changing any existing class. - Medium: Apply ISP — split
UserRepositoryintoUserReadRepository(findByEmail) andUserWriteRepository(save). Wire upUserServiceto depend only on the relevant interface. - Hard: Implement an
AuditedUserRepositorythat wrapsJpaUserRepositoryand logs everysave()call. Use DIP — inject it by overriding the primary bean in a@Configurationclass without modifyingUserServiceorJpaUserRepository.
Back to Topic
Return to the SOLID Principles note for theory, interview questions, and further reading.