What is Spring Framework?
The Spring Framework is like a toolbox for Java developers, designed to make building applications easier and more efficient. It offers a collection of tools, libraries, and features that help developers manage complex tasks, such as connecting to databases, handling security, and coordinating the interactions between different parts of an application.
For example, in a web application, when a user submits a login form, this request is sent to a controller (one part of the application) that checks the user credentials. The controller then calls a service (another part) that connects to the database to verify the user’s information. If the credentials are correct, the user is authenticated, and the application sends a response back. Spring helps manage the flow between the controller, service, and database, ensuring that each part interacts seamlessly without developers needing to manually handle all the connections.
If that was too confusing, let’s use an analogy: building a house. For different tasks—like hammering nails, measuring wood, or laying bricks—you need different tools. Spring is like a well-organized toolbox that provides everything you need, so you don’t have to search for tools individually. This saves time, reduces mistakes, and keeps your work streamlined. All you need to know is which tool to use and when to use it. But how does Spring achieve this?
At its core, Spring focuses on two big ideas:
- Dependency Injection – Spring helps the different parts of your application work together without needing to manually connect them. Spring “injects” these parts where they’re needed. Here’s an example.
Without Dependency Injection:
// AT&T Service Implementation in ATTService.java
public class ATTService {
public String connect() {
return "Connected to AT&T!";
}
}
// CellPhone Class in CellPhone.java
public class CellPhone {
private ATTService attService;
// Direct instantiation
public CellPhone() {
this.attService = new ATTService();
}
public void makeCall() {
System.out.println(attService.connect());
}
public static void main(String[] args) {
CellPhone phone = new CellPhone();
phone.makeCall(); // Outputs: Connected to AT&T!
}
}
An issue with this implementation is that it introduces tight coupling between the CellPhone
class and the ATTService
. The CellPhone
class is hardcoded to use ATTService
, which means that if we wanted to switch to another service like Verizon, we would need to modify the constructor of the CellPhone
class directly to instantiate a Verizon object instead. This makes the code less flexible and harder to maintain.
With Dependency Injection:
// Main Application Class in CellServiceDemoApplication.java
@SpringBootApplication
public class CellServiceDemoApplication {
public static void main(String[] args) {
var context = SpringApplication.run(CellServiceDemoApplication.class, args);
CellPhone phone = context.getBean(CellPhone.class);
phone.makeCall(); // Outputs: Connected to AT&T! or Connected to Verizon!
}
}
// Cell Service Interface in CellService.java
interface CellService {
String connect();
}
// AT&T Service Implementation in ATTService.java
@Service
class ATTService implements CellService {
@Override
public String connect() {
return "Connected to AT&T!";
}
}
// Verizon Service Implementation in VerizonService.java
@Service
class VerizonService implements CellService {
@Override
public String connect() {
return "Connected to Verizon!";
}
}
// CellPhone Class in CellPhone.java
@Component
class CellPhone {
private final CellService cellService;
// Constructor Injection with @Qualifier to specify the AT&T service
@Autowired
public CellPhone(@Qualifier("ATTService") CellService cellService) {
this.cellService = cellService;
}
public void makeCall() {
System.out.println(cellService.connect());
}
}
This approach achieves loose coupling by having both ATTService
and VerizonService
implement the CellService
interface, which serves as a common contract for cell services. The CellPhone
class depends on the CellService
interface rather than on a specific implementation. At runtime, Spring injects the appropriate implementation of this interface based on our configuration. The @Qualifier("ATTService")
annotation in the CellPhone
constructor specifies that we want Spring to inject the AT&T implementation instead of Verizon, allowing for greater flexibility in service selection.
While I won’t delve into the details of Spring context and common Spring annotations in this post, you can find more information in the separate articles I’ve linked to. In the future, if we introduce a new CellService
provider, such as T-Mobile, we can seamlessly switch between all three services without modifying the CellPhone
class.
- Aspect-Oriented Programming (AOP) – In software development, certain tasks, known as cross-cutting concerns, frequently arise across different parts of an application. These common tasks—such as logging, security, or error handling—can complicate your code if mixed with your core business logic. This is where AOP comes in. AOP allows you to manage these concerns separately, helping you maintain a clean and organized codebase. Let’s look at an example to illustrate how this works.
Without Spring AOP:
// OrderService.java
public class OrderService {
public void processOrder() {
long startTime = System.currentTimeMillis();
// Simulate order processing logic
System.out.println("Processing order...");
try {
Thread.sleep(2000); // Simulating a delay
} catch (InterruptedException e) {
e.printStackTrace();
}
long endTime = System.currentTimeMillis();
System.out.println("Order processed in " + (endTime - startTime) + " milliseconds.");
}
public static void main(String[] args) {
OrderService orderService = new OrderService();
orderService.processOrder(); // Outputs processing time
}
}
When measuring the performance of our processOrder()
method, the timing logic is directly embedded within the method itself. However, if we wanted to measure the performance of multiple functions across our application, we would end up with a significant amount of duplicate code. This repetition not only clutters our codebase but also compromises the clarity of our business logic. Each method would require its own timing logic, making the code harder to maintain and read.
With Spring AOP:
// Main Application Class in AopPerformanceDemoApplication.java
@SpringBootApplication
public class AopPerformanceDemoApplication {
public static void main(String[] args) {
var context = SpringApplication.run(AopPerformanceDemoApplication.class, args);
OrderService orderService = context.getBean(OrderService.class);
orderService.processOrder(); // Outputs processing time
}
}
// Order Service in OrderService.java
@Service
class OrderService {
public void processOrder() {
// Simulate order processing logic
System.out.println("Processing order...");
try {
Thread.sleep(2000); // Simulating a delay
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
// Performance Logging Aspect in PerformanceAspect.java
@Aspect
@Component
class PerformanceAspect {
@Around("execution(* OrderService.processOrder(..))")
public Object measurePerformance(ProceedingJoinPoint joinPoint) throws Throwable {
long startTime = System.currentTimeMillis();
// Proceed with the actual method execution
Object result = joinPoint.proceed();
long endTime = System.currentTimeMillis();
System.out.println("Order processed in " + (endTime - startTime) + " milliseconds.");
return result;
}
}
Note: In this Spring Boot application, there’s no need to add @EnableAspectJAutoProxy
to enable AOP features; simply adding the spring-boot-starter-aop
dependency in your pom.xml
handles configuration automatically.
The PerformanceAspect class uses Spring AOP to measure the performance of the processOrder()
method in the OrderService
class. By employing the @Around
annotation, we can intercept method execution for performance timing without modifying the original logic. The parameter in the @Around
annotation is an AspectJ pointcut expression that tells Spring how to intercept methods. The ProceedingJoinPoint
parameter represents the intercepted method, and invoking joinPoint.proceed()
executes it.
We capture the start time before method execution and measure the elapsed time after it completes by comparing timestamps. This keeps performance measurement separate from core functionality, allowing for easy measurement of other methods within the same aspect and reducing code duplication.
With Spring, we can relinquish control to the framework, allowing it to manage the various parts of our application and their connections. This concept is known as “inversion of control.” As a result, we don’t need to manually wire everything together or create new objects in a class’s constructor; instead, we can concentrate on writing our business logic. Spring takes care of the rest, making our code easier to read and maintain while enabling quick changes and improvements.
It’s important to note that Spring is a vast ecosystem, and we’ve only scratched the surface by focusing on Spring Core. For instance, Spring Data simplifies database connections and efficiently manages data operations, while Spring Security provides essential features for securing our applications, including user authentication and authorization. This modular approach allows us to select the components of Spring that best fit our project’s needs, ensuring we only include what’s necessary.