Oops
Object-Oriented Programming (OOP) promotes modeling complex entities and processes through objects that encapsulate both state and behavior. It emphasizes principles like encapsulation—where an object's behavior controls access to its internal state—and polymorphism, which allows different types of entities to be handled through a shared interface or common set of operations. The specific ways these principles are implemented can differ across object-oriented languages.
Although some developers are quick to proclaim that object-oriented programming is a failed approach, the reality is more nuanced. Like any tool, OOP excels in certain contexts and falls short in others. Poorly applied OOP can lead to frustrating and overly complex designs, and many have encountered exaggerated or misguided implementations of its principles. However, by understanding where OOP shines and where it doesn't, we can make informed decisions—leveraging it when it adds value and opting for alternative approaches when it doesn't.
Modeling a Factory Method Using Switch Expressions, Interfaces, and Classes with oops
we'll explore a simple and effective way to implement the Factory Method design pattern in Java using language features such as switch expressions, interfaces, and class-based polymorphism.
We’ll start by defining an enum to represent different types of email events:
public enum EmailType {
VERIFICATION,
PASSWORD_RECOVERY,
TENANT_WELCOME
}
public interface IEmailEventService {
void send(String messageBrokerQueue);
}
Next, we define a common interface that all email event services will implement. This allows us to encapsulate behavior behind a unified contract—an essential aspect of object-oriented polymorphism:
public class EmailEventService implements IEmailEventService{
@Override
public void send(String messageBrokerQueue) {
}
}
public class PasswordRecoveryEventService implements IEmailEventService{
@Override
public void send(String messageBrokerQueue) {
}
}
public class TenantMessagingService implements IEmailEventService{
@Override
public void send(String messageBrokerQueue) {
}
}
Lets build a factory method that uses a switch expression on the EmailType enum to dynamically instantiate the appropriate service class.
By combining encapsulation, polymorphism, we can build clean and maintainable code that adheres to solid design principles.
public class EmailEventFactory {
private final IEmailEventService verifyEmailEventService;
private final IEmailEventService passwordRecoveryEmailEventService;
private final IEmailEventService tenantWelcomeEmailEventService;
public EmailEventFactory() {
this.verifyEmailEventService = new EmailEventService();
this.passwordRecoveryEmailEventService = new PasswordRecoveryEventService();
this.tenantWelcomeEmailEventService = new TenantMessagingService();
}
public void getEmailEventService(EmailType type) {
switch (type){
case VERIFICATION: this.verifyEmailEventService.send("verify");
case PASSWORD_RECOVERY:this.passwordRecoveryEmailEventService.send("passwordRecovery");
case TENANT_WELCOME: this.tenantWelcomeEmailEventService.send("tenantWelcome");
break;
}
}
}
At first glance, this implementation appears clean and effective—and in many contexts, it is. However, it comes with some limitations that can lead to runtime issues if not carefully handled.
Potential Issues with This Approach
Lack of Exhaustiveness Checking
If you add a new constant to the EmailType enum, the compiler won’t warn you that your switch expression is missing a corresponding case. This can silently introduce bugs.Risk of Runtime Exceptions
If an unhandled EmailType is passed to the method, it will simply be ignored, or worse—lead to unexpected behavior. To mitigate this, it's common to add a default case:
switch (type) {
case VERIFICATION -> verifyEmailEventService.send("verify");
case PASSWORD_RECOVERY -> passwordRecoveryEmailEventService.send("passwordRecovery");
case TENANT_WELCOME -> tenantWelcomeEmailEventService.send("tenantWelcome");
default -> throw new IllegalArgumentException("Unsupported email type: " + type);
}
- No Compile-Time Guarantee Even with a default case, the compiler still cannot ensure all enum values are covered. This shifts the responsibility to the developer to handle all possible cases correctly.
Note - While modeling a factory using switch expressions is straightforward and often sufficient for small-scale applications, it has limitations in scalability and safety.
Data-oriented
Data-oriented programming promotes modeling information as immutable data and separating the business logic that operates on it. As the shift toward simpler, more focused programs has gained momentum, Java has introduced new features to better support this style—such as records for straightforward data modeling, sealed classes for representing fixed sets of alternatives, and pattern matching for flexible and expressive handling of polymorphic data.
Records, sealed classes, and pattern matching are complementary features in Java that collectively enhance support for data-oriented programming.
- Records provide a concise way to model immutable data structures.
- Sealed classes enable precise modeling of constrained hierarchies, allowing developers to define a fixed set of subclasses.
- Pattern matching offers a type-safe and expressive mechanism for working with polymorphic data.
Java has introduced pattern matching in progressive stages:
- Initially, only type-test patterns were supported in instanceof checks.
- Subsequent enhancements extended support to switch statements, enabling more expressive control flow.
- Most recently, deconstruction patterns for records were introduced in Java 19, allowing developers to extract and operate on the internal components of records in a streamlined, declarative manner.
These features together promote cleaner, more maintainable code by aligning data representation closely with the logic that operates on it.
Refactoring with a Data-Oriented Approach
Let’s now revisit the factory method from an earlier example and refactor it using data-oriented principles. By leveraging records, sealed interfaces, and switch expressions, we achieve improved readability, stronger compile-time safety, and better separation of concerns.
@Component
public record EmailEventFactory(VerifyEmailEventService verifyEmailEventService,
PasswordRecoveryEventService passwordRecoveryEventService,
TenantMessagingService tenantMessagingService
) {
public IEmailEventService getEmailEventService(EmailType type) {
return switch (type) {
case VERIFICATION , TENANT_EMAIL_VERIFICATION-> verifyEmailEventService;
case PASSWORD_RECOVERY -> passwordRecoveryEventService;
case TENANT_WELCOME -> tenantMessagingService;
};
}
}
public sealed interface IEmailEventService permits EmailEventService, PasswordRecoveryEventService, TenantMessagingService, UserMessagingService {
void send(String messageBrokerQueue);
}
The sealed interface ensures that all possible implementations are known and controlled, enhancing safety and discoverability.
@Service
public record EmailEventService() implements IEmailEventService {
@Override
public void send(String messageBrokerQueue) {
// Do the things
}
Each service is implemented as a record, aligning with the principles of immutability and clear separation of concerns:
@Service
public record PasswordRecoveryEventService() implements IEmailEventService {
@Override
public void send(String messageBrokerQueue) {
// Do the things
}
@Service
public record UserMessagingService() implements IEmailEventService {
@Override
public void send(String messageBrokerQueue) {
// Do the things
}
Benefits of This Refactoring
- Compile-time safety: Thanks to sealed interfaces and exhaustive switch expressions.
- Immutability: Record types make the data model inherently immutable.
- Clean separation of logic: Each service handles a distinct responsibility, adhering to the Single Responsibility Principle.
Conclusion
The integration of records, sealed types, and pattern matching simplifies adherence to data-oriented principles, resulting in code that is more concise, readable, and reliable. While treating data as data may feel unfamiliar within Java’s object-oriented foundation, these modern features offer powerful tools that are well worth incorporating into our development practices.
Top comments (1)
What a fantastic comparison between OOP and data-oriented design in Java! The way you refactored using records and sealed interfaces really showcases how modern Java can enhance safety, clarity, and structure.
I loved the mix of theory and practical code—especially how you highlighted immutability and compile-time guarantees. More Java developers should definitely dive into this hybrid approach!