Imagine navigating the world of Java development without the convenience of Dependency Injection (DI) frameworks like Spring or Guice. What if you had to manage every component and its dependencies manually? It might sound daunting, but there’s a certain charm and depth in this alternative approach: coding without a DI framework.
This article takes you on an intriguing journey through the lesser-traveled path of Java development, unraveling the nuances of manually controlling component creation and dependencies.
Get ready to dive into a realm where simplicity meets effectiveness, as we demonstrate a hands-on method to replicate and understand features typically handled by DI frameworks. Let the adventure begin!
Manual dependency management in Java represents a stark contrast to the automated convenience of frameworks like Spring. At its core, this method involves explicitly defining and managing the relationships and dependencies between different components in a Java application. Unlike Spring’s automated dependency injection, which seamlessly wires components together, manual management demands a more in-depth understanding of how these components interact and depend on each other.
This approach necessitates a granular level of control over the lifecycle of each component, from creation to destruction. Developers must consciously design and implement the logic for creating instances, handling dependencies, and managing the lifecycle of each component in their application. It’s a process that requires careful planning and a thorough understanding of the application’s architecture, as well as the roles and responsibilities of each component within it.
Opting for manual dependency management often leads to more boilerplate code and a hands-on approach to managing component relationships, providing developers with a clearer insight into the inner workings of their application. This method shines in scenarios where customized control is paramount, or in environments where using a full-fledged framework like Spring isn’t feasible or necessary.
For the purpose of this article and to demonstrate the concept of not using dependency injection, we start out with this use case: In this scenario, we explore the process of a user registering with the system by providing their email and personal information. The system validates this information, ensures it’s unique, and upon successful registration, sends a confirmation email to the user. This use case involves the direct participation of the user and the system, detailing the step-by-step flow from initial data submission to the completion of registration.
A user can register by providing their email and personal information. After successful registration, the system sends a confirmation email to the user.
The user registration process in a Java application without a Dependency Injection (DI) framework can be complex, involving multiple components interacting with each other. A sequence diagram helps in visualizing the flow of this process. The diagram below outlines the sequence of interactions between different components during user registration.
Figure 1. User Registration Flow
This sequence diagram effectively illustrates the flow of actions and interactions between different components during the user registration process in a Java application, specifically
in one that manages dependencies manually. The diagram underscores the need for clear communication and coordination between various components, highlighting the importance of well-defined interfaces and responsibilities in each part of the system.
Through this interaction flow, the diagram showcases how the application handles a user registration request, from receiving the initial user data to persisting it in the database and sending out a confirmation email. It also reflects the layered architecture commonly used in Java applications, where the service layer (UserService) acts as a mediator between the presentation layer (represented by the User actor) and the data access layer (UserDAO), with additional services like EmailService providing specific functionalities.
This visual representation aids in understanding the intricacies and dependencies involved in the process, making it easier to identify potential areas for optimization or refactoring, especially in a setup where dependency injection is not utilized. By breaking down the process into discrete steps and clarifying the role of each component, developers can ensure that each part of the system functions efficiently and cohesively, leading to a robust and reliable user registration feature in the application.
The ApplicationContext class in Java, especially in scenarios without a Dependency Injection (DI) framework like Spring, is crucial for managing the lifecycle and dependencies of various components within an application. This class acts as a custom-built alternative to DI frameworks, allowing for manual control and instantiation of application components.
In the updated implementation of the ApplicationContext, several key components are defined, each playing a vital role in the application’s functionality:
UserService (UserServiceImpl): This singleton service is responsible for user-related operations, such as user management and interactions with the data access layer.
EmailService (EmailServiceImpl): Another singleton service, this component handles email-related functionalities, including the preparation and sending of emails through the EmailSender.
UserDAO (UserDAOImpl): Defined as a prototype, the UserDAO is responsible for data access operations, particularly for user data. Being a prototype means that a new instance of UserDAOImpl is created whenever it is requested.
EmailSender (EmailSenderImpl): Also a prototype, this component is the actual mechanism for sending emails, such as interfacing with an SMTP server or another email dispatch system.
Code Listing 1. The ApplicationContext
public class ApplicationContext {
private static class Holder {
private static final ApplicationContext CTX = new ApplicationContext();
private static final UserService USER_SERVICE = new UserServiceImpl();
private static final EmailService EMAIL_SERVICE = new EmailServiceImpl();
}
public static ApplicationContext getInstance() {
return Holder.CTX;
}
public UserService userService() {
return Holder.USER_SERVICE;
}
public EmailService emailService() {
return Holder.EMAIL_SERVICE;
}
// Prototype
public UserDAO userDAO() {
return new UserDAOImpl();
}
// Prototype
public EmailSender emailSender() {
return new EmailSenderImpl();
}
}
The ApplicationContext uses a static nested class Holder to initialize and hold these components. The use of the static nested class pattern ensures that the singleton instances of UserService and EmailService are created lazily and are thread-safe without requiring synchronization.
The getInstance() method provides a global point of access to the ApplicationContext, ensuring consistent use of the same instance across the application.
The methods userService(), emailService(), userDAO(), and emailSender() provide access to the respective components. The distinction between singleton and prototype scoped components is evident here. While UserService and EmailService are singletons, UserDAO and EmailSender
are created anew with each call, adhering to the prototype pattern. This distinction is crucial in managing the state and lifecycle of these components:
Singleton Components: The single instance of UserService and EmailService ensures that these services maintain their state across the application, providing a consistent point of interaction for all components that depend on them.
Prototype Components: The prototype scope of UserDAO and EmailSender allows for the creation of a new instance each time they are requested. This approach is beneficial for components that need to be stateless or where each operation requires a fresh instance to avoid shared state issues.
In practice, the ApplicationContext enables centralized control over the creation and provisioning of core components, contrasting with Dependency Injection (DI) frameworks which automate dependency management, reducing manual control but simplifying the process. For example, when a part of the application requires access to UserService, it calls ApplicationContext.getInstance().userService(), ensuring that it interacts with the same instance of UserService throughout the application.
Similarly, when a new instance of UserDAO or EmailSender is needed, the application calls userDAO() or emailSender() methods, respectively, on the ApplicationContext. This manual management of component instantiation and dependency handling allows for a clear understanding of the application’s flow and dependencies, providing a more hands-on approach compared to automated DI frameworks.
The updated ApplicationContext structure thus becomes a pivotal element in the application, demonstrating an effective way to manage dependencies and component lifecycles in Java applications, especially in scenarios where the use of a DI framework like Spring is not preferred or feasible.
The implementation of the ApplicationContextSupport interface in Java plays a pivotal role in streamlining the process of dependency resolution within an application, especially in environments without traditional Dependency Injection (DI) frameworks. This interface, equipped with default methods, significantly simplifies the task of accessing dependent objects.
The ApplicationContextSupport interface is designed to provide default methods that directly interface with the ApplicationContext. These methods act as shortcuts to access various components like UserDAO, EmailSender, UserService, and EmailService. By doing so, it eliminates the repetitive task of fetching the ApplicationContext instance and then calling the specific method to obtain the required object.
Code Listing 2. The ApplicationContextSupport
public interface ApplicationContextSupport {
default ApplicationContext applicationContext() {
return ApplicationContext.getInstance();
}
default UserDAO userDao() {
return applicationContext().userDAO();
}
default EmailSender emailSender() {
return applicationContext().emailSender();
}
default UserService userService() {
return applicationContext().userService();
}
default EmailService emailService() {
return applicationContext().emailService();
}
}
In the given example, a class implementing ApplicationContextSupport can straightforwardly access dependencies. For instance:
// UserServiceImpl implements ApplicationContextSupport
@Override
public User findUser(String email) throws UserNotFoundException {
return userDao().findUserByEmail(email));
}
In this scenario, the findUser method utilizes the userDao() from ApplicationContextSupport to access the UserDAO. This method abstracts the underlying complexity of fetching the ApplicationContext and then the UserDAO. Instead of writing ApplicationContext.getInstance().userDAO().findUserByEmail(email), the method call is succinctly reduced to userDao().findUserByEmail(email).
Code Clarity: Implementing this interface brings clarity to the code. Developers can focus on business logic rather than dealing with the intricacies of fetching dependencies.
Reduced Boilerplate: It significantly cuts down the boilerplate code, as there’s no need to repeatedly call ApplicationContext.getInstance().
Enhanced Readability: The code becomes more readable and maintainable, enhancing overall development efficiency.
Flexibility: This approach offers flexibility in managing dependencies and
can be easily adapted or extended for additional components, should the need arise.
To sum up, the ApplicationContextSupport interface with its default methods provides a convenient and efficient way to access and manage dependencies in a Java application. This design not only simplifies the process of dependency resolution but also aligns well with best practices of clean and maintainable code, particularly in environments that opt out of using conventional DI frameworks.
Let’s start out with a test-driven approach to this implementation, a methodology that not only ensures the robustness of our code but also guides the development process in a structured manner. Test-Driven Development (TDD) in Java is particularly effective when exploring alternative methods like coding without a Dependency Injection (DI) framework. This approach allows us to first define the expected behavior through tests and then develop the implementation to meet these expectations.
The code snippet provided is a JUnit test case for the user registration functionality in a Java application. This test is part of the UserServiceTest class which implements the ApplicationContextSupport interface. The interface simplifies the process of accessing the application context and its dependencies, as demonstrated in the test method registerUser(). For the purpose of simplicity, this test will not verify the confirmation email.
Code Listing 3. Register User Test
class UserServiceTest implements ApplicationContextSupport {
@Test
void registerUser() {
User newUser = User.builder().email("steve.rogers@gmail.com")
.first("Steve").last("Rogers")
.active(true).build();
User createdUser = userService().registerUser(newUser);
assertThat(createdUser).isNotNull().satisfies(u -> {
assertThat(u.getId()).as("Id")
.isGreaterThan(0L);
assertThat(u.getEmail()).as("Email")
.isEqualTo("steve.rogers@gmail.com");
assertThat(u.getFirst()).as("First Name")
.isEqualTo("Steve");
assertThat(u.getLast()).as("Last Name")
.isEqualTo("Rogers");
});
}
}
Creating a New User: The test begins by building a new User object, newUser, with predefined attributes. This step mimics the data input from a user during the registration process.
Registering the User: It then calls the registerUser(newUser) method on userService(), obtained through ApplicationContextSupport. This step is critical as it tests the actual registration process implemented in the UserService.
Assertions: The test uses assertThat from the AssertJ library to validate the outcome. It ensures that the createdUser:
By adopting a TDD approach, we validate the functionality of our user registration process in a controlled environment. It allows us to confirm that the application behaves as expected even without relying on a DI framework. Moreover, it ensures that any changes in the application’s code can be immediately tested against these defined behaviors, maintaining the reliability and stability of the application over time.
In brief, this test-driven approach is not just a best practice but an essential part of developing robust Java applications, particularly when exploring alternative methods like manual dependency management.
In contexts without a Dependency Injection (DI) framework, mocking the ApplicationContext becomes a vital strategy for effective unit testing. It allows for simulating the application’s environment and dependencies in a controlled manner. This approach is especially useful for testing classes that depend on the ApplicationContext for their dependencies. By mocking the ApplicationContext, we can ensure that our unit tests are focused solely on the class under test, without any interference from the actual implementation of the dependencies.
To demonstrate this, let’s consider a test case for the UserService class, where we mock the ApplicationContext and use it to provide mock dependencies. Here’s an example using Mockito:
@ExtendWith(MockitoExtension.class)
public class UserServiceIntegrationTest {
@Mock
private UserDAO userDAOMock;
@Mock
private EmailService emailServiceMock;
@Mock
private ApplicationContext applicationContextMock;
@Spy
private UserServiceImpl userService;
@BeforeEach
public void setUp() throws Exception {
when(applicationContextMock.userDAO()).thenReturn(userDAOMock);
when(applicationContextMock.emailService()).thenReturn(emailServiceMock);
doReturn(applicationContextMock).when(userService).applicationContext();
}
@Test
public void testRegisterUser() {
User newUser = User.builder().email("steve.rogers@gmail.com")
.first("Steve")
.last("Rogers")
.build();
doNothing().when(userDAOMock).createUser(newUser);
when(userDAOMock.findUserByEmail(anyString())).thenReturn(of(newUser));
User registeredUser = userService.registerUser(newUser);
verify(userDAOMock).createUser(newUser);
verify(userDAOMock).findUserByEmail(newUser.getEmail());
verify(emailServiceMock).confirmEmailAddress(newUser);
assertThat(registeredUser).isNotNull();
// Additional assertions as needed
}
}
In this setup:
The @Spy annotation on UserServiceImpl creates a spy around an instance of UserServiceImpl, allowing the testing of real methods with the option to override certain calls.
Mocks for UserDAO and EmailService are created and then injected into the ApplicationContext mock.
The applicationContext() method in UserServiceImpl is stubbed to return the mocked ApplicationContext. This ensures that when the class under test calls applicationContext(), it gets a controlled, mock environment.
The testRegisterUser() method constructs a test scenario where a new user is registered, verifying the interactions with the mocked dependencies and asserting the expected outcomes.
This approach, where the ApplicationContext is mocked and its methods stubbed to return specific mocks, offers a powerful and flexible way to test classes in isolation. It demonstrates the efficacy of using Mockito for testing in Java, especially in situations where dependency injection is manually handled and not facilitated by frameworks like Spring.
As you can see, mocking can be quite involved. In this case, we had to use a spy to effectively oversee the real behavior of UserServiceImpl while having the ability to control its interaction with dependencies. This combination of spies and mocks is essential when you want to test the functionality of a class in a near-real scenario, but with full control over external dependencies. It provides a delicate balance between testing with real objects and ensuring predictable outcomes by mocking external interactions.
Using a spy is particularly useful when you need to test the class’s actual code but override some of its behavior, typically external calls. However, it’s important to use this technique judiciously as it can lead to tests that are more complex and potentially fragile. In the context of manual dependency management, as opposed to automatic DI, such complexities are more common, and understanding how to effectively leverage Mockito’s spying and mocking capabilities becomes crucial for writing comprehensive and reliable tests.
The UserService in this Java application exemplifies a sophisticated approach to managing dependencies, particularly in an environment where Dependency Injection (DI) frameworks are not utilized. The key aspect of this implementation is its reliance on the ApplicationContextSupport interface to access dependent objects. This design choice highlights the utility of the interface in simplifying dependency management.
By implementing ApplicationContextSupport, UserServiceImpl inherits methods that provide easy access to other components in the application, like UserDAO and EmailService. This approach is a form of behavioral inheritance, where UserServiceImpl gains the behavior of accessing dependencies through methods defined in ApplicationContextSupport. It’s a strategic design that leverages interface default methods to reduce boilerplate code and centralize the logic for accessing application components.
Registration Process: The registerUser() method in UserServiceImpl demonstrates a clear and efficient process for registering a new user. It first calls userDao().createUser(user) to persist the user’s information. Following this, it retrieves the newly created user by email and throws a CreateUserFailedException if the user is not found. Finally, it uses emailService().confirmEmailAddress(createdUser) to send a confirmation email, demonstrating a seamless integration of various components.
Finding a User: The findUser() method provides a concise way to retrieve a user by their email. It uses a fluent API style with Optional, calling userDao().findUserByEmail(email) and handling the case where the user is not found by throwing a UserNotFoundException.
Code Listing 4. The UserService Implementation
public class UserServiceImpl implements UserService, ApplicationContextSupport {
@Override
public User registerUser(User user) throws BusinessException {
userDao().createUser(user);
String email = user.getEmail();
var createdUser = userDao().findUserByEmail(email)
.orElseThrow(() -> new CreateUserFailedException(email));
emailService().confirmEmailAddress(createdUser);
return createdUser;
}
@Override
public User findUser(String email) throws UserNotFoundException {
return ofNullable(email)
.flatMap(em -> userDao().findUserByEmail(em))
.orElseThrow(() -> new UserNotFoundException(email));
}
}
The incorporation of ApplicationContextSupport in UserServiceImpl is a strategic move that brings several benefits:
Simplified Access to Dependencies: Instead of manually fetching instances from the ApplicationContext, the service class can directly access dependencies like UserDAO and EmailService through inherited methods. This simplifies the code and makes it more readable.
Decoupling of Service Logic from Dependency Management: By inheriting dependency access methods, UserServiceImpl keeps its focus on business logic, leaving dependency management to ApplicationContextSupport. This separation of concerns leads to cleaner and more maintainable code.
Consistency Across Services: As other service classes in the application can also implement ApplicationContextSupport, this creates a consistent pattern for accessing dependencies, enhancing the overall design and scalability of the application.
To recap, the UserService implementation in this Java application demonstrates an effective approach to managing dependencies without a DI framework. By leveraging the ApplicationContextSupport interface, it achieves a clean, maintainable, and efficient way of integrating and utilizing various components of the application. This approach not only simplifies dependency resolution but also enriches the service class with clear and focused business logic.
The code below introduces the UserDAOImpl, a crucial component of our Java application that manages the data access layer, specifically for user-related data. It stands as an example of how to implement essential database operations without the aid of a Dependency Injection framework, showcasing an alternative yet effective approach to handling data persistence and retrieval in Java.
Code Listing 5. The User Data Access Object
public class UserDAOImpl implements UserDAO {
private static final Random RANDOM_ID = new Random(1000L);
private final static Map<String, User> dataSource = new HashMap<>();
@Override
public Optional<User> findUserByEmail(String email) {
// Example: A sql datasource to query to retrieve user info
return ofNullable(dataSource.get(email));
}
public void createUser(User user) {
String email = ofNullable(user).map(User::getEmail)
.orElseThrow(() -> new IllegalArgumentException("Email missing"));
if (dataSource.containsKey(user.getEmail())) {
throw new UserExistsException(email);
}
final User newUser = user.withId(RANDOM_ID.nextLong(0, Long.MAX_VALUE));
dataSource.put(email, newUser);
}
}
The code listing provided reveals the EmailServiceImpl, an integral part of our application, designed to manage email-related functionalities. This implementation not only handles the process of sending confirmation emails to users but also demonstrates the effective use of ApplicationContextSupport for streamlined access to dependent services like EmailSender.
Code Listing 6. The Email Service Implementation
public class EmailServiceImpl implements EmailService, ApplicationContextSupport {
/**
* Email confirm the email address on the newly created account.
*/
@Override
public void confirmEmailAddress(User user) {
String email = user.getEmail();
String subject = "Confirm Email Address";
String body = "Please confirm email address: " + email;
emailSender().sendEmail(email, subject, body);
}
}
The EmailSenderImpl class, as showcased here, serves as a fundamental component of our application’s email infrastructure. This class encapsulates the specifics of email dispatch, highlighting a simplified yet efficient approach to managing email communications within a Java environment.
For demonstration purposes, the implementation details within EmailSenderImpl are left blank. However, in a practical scenario, this is where one could integrate robust email services such as AWS Simple Email Service (SES) to handle email dispatch efficiently.
Code Listing 7. The Email Sender Implementation
public class EmailSenderImpl implements EmailSender {
@Override
public void sendEmail(String email, String subject, String body) {
// implementation details
}
}
This article’s journey through Java development without Dependency Injection (DI) frameworks like Spring or Guice offers a unique perspective, revealing the intricacies and challenges of manual dependency management. Through the implementation of components like UserService, UserDAO, and EmailService, and the strategic use of ApplicationContextSupport, we gained a deeper understanding of the underlying processes and architecture of Java applications.
The hands-on experience with a DI-less environment makes one truly appreciate the sophistication and convenience of DI frameworks. These frameworks, often taken for granted, significantly streamline development by handling the complexities of dependency management and component lifecycle.
Furthermore, the test-driven approach in this exploration mirrors real-world scenarios, demonstrating both the positive and negative aspects of manual management in production code. Experiencing these challenges firsthand not only enhances our appreciation for DI frameworks but also enriches our understanding of their importance in simplifying and improving the reliability of our code.
In conclusion, this article underscores the value of DI frameworks in modern software development, while also emphasizing the importance of understanding core principles of Java programming. Whether it’s the clarity brought by manual dependency management or the ease provided by DI frameworks, each approach offers valuable insights, contributing to our growth and adaptability as Java developers.
The entire source for this article is available on Github at kapresoft/kapresoft-examples