Post

EJB vs. Spring: Architectural Evolution

EJB vs. Spring: Architectural Evolution

In the world of enterprise Java development, few topics are as pivotal as the evolution from traditional Java EE (Enterprise Edition) architectures to the lightweight, modular approach of the Spring Framework. Understanding the underlying mechanisms of these technologies—specifically how they handle middleware, transactions, and object lifecycles—is crucial for building robust, scalable applications.

In this deep dive, I will synthesize the core concepts of Enterprise JavaBeans (EJB) and Spring, exploring how they solve similar problems through radically different philosophies.


1. The Java EE Paradigm: Structured & Server-Centric

Java EE was designed as a standardized architecture for enterprise-scale applications, emphasizing a three-layer model: the client tier, the web tier (handling HTTP/servlets), and the business logic tier (where EJB resides), all anchored by the data tier.

The core value proposition of Java EE is the Application Server. This server is not just a runtime; it is a comprehensive ecosystem that provides essential services out-of-the-box, such as multithreading, security, transaction management, and persistence.

image.png

The Role of Enterprise JavaBeans (EJB)

At the heart of the business logic tier lies the EJB. These are distributed server-side components that encapsulate business logic. They operate within an EJB Container, which abstracts away complex infrastructure concerns.

There are different flavors of EJBs, but the most common are:

  • Session Beans: These invoke business logic. They can be Stateless (no client-specific state, allowing the container to pool instances for scalability) or Stateful (maintaining a conversation with a specific client).
  • Message-Driven Beans: These act as listeners for asynchronous messaging systems (like JMS), allowing for decoupled event processing.

Explicit vs. Implicit Middleware

One of the most fascinating aspects of EJB history is the shift from explicit to implicit middleware.

In early distributed systems, developers often had to write “explicit” middleware code—manually handling network connections, serialization, and service lookups inside their business logic. This led to bloated code that was difficult to maintain or refactor.

Modern EJB (and Spring) utilizes implicit middleware. Here, you write pure business logic, and the infrastructure concerns are applied transparently via configuration (annotations or XML).

How it works under the hood:

When a client asks for a bean, they never get the actual object. Instead, the container generates a proxy (stub). When a method is called on this proxy, the container intercepts the call, executes necessary services (like starting a transaction or checking security permissions), and then delegates execution to the actual bean instance.

EJB Code Example: Stateless Session Bean

Here is a hypothetical scenario for a warehouse system. Notice how clean the code is; the @Stateless annotation triggers the container to manage the lifecycle, threading, and pooling.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import javax.ejb.Stateless;
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;

@Stateless // Marks this as a pool-able, stateless component
public class InventoryManager {

    // The container injects the persistence context automatically
    @PersistenceContext
    private EntityManager entityManager;

    public void restockItem(Long itemId, int quantity) {
        // Business logic focuses purely on the domain, not infrastructure
        Item item = entityManager.find(Item.class, itemId);
        if (item != null) {
            item.addStock(quantity);
            // No need to manually call 'commit' - the container handles it 
        }
    }
}

2. Transaction Management: The Container’s Heavy Lifting

Transaction management is arguably the most critical service provided by these frameworks. In Java EE, we distinguish between Bean-Managed (programmatic) and Container-Managed (declarative) transactions.

Container-Managed Transactions (CMT)

In CMT, the container intercepts the method call and starts a transaction before the method begins, committing it when the method returns.

  • Rollback Rules: By default, the container automatically rolls back the transaction if a RuntimeException (unchecked) is thrown. It will not rollback for checked exceptions unless explicitly configured.
  • Propagation: We can control how transactions stack using @TransactionAttribute. For example, REQUIRES_NEW suspends the current transaction (T1) and starts a fresh one (T2), ensuring that T2’s success or failure is independent of T1.

Scenario: A banking transfer where the audit log must be saved even if the transfer fails.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import javax.ejb.Stateless;
import javax.ejb.TransactionAttribute;
import javax.ejb.TransactionAttributeType;

@Stateless
public class TransferService {

    @EJB
    private AuditService auditService;

    // Default is REQUIRED: Join existing transaction or start new
    public void transferFunds(Account from, Account to, double amount) {
        try {
            // ... logic to debit/credit ...
        } catch (RuntimeException e) {
            // Log failure in a separate transaction so the log persists
            // even though the transfer rolls back.
            auditService.logFailure(from.getId(), "Transfer failed"); 
            throw e; // Triggers rollback for transferFunds 
        }
    }
}

@Stateless
public class AuditService {
    
    // Suspend the caller's transaction; run this in a new independent context
    @TransactionAttribute(TransactionAttributeType.REQUIRES_NEW)
    public void logFailure(String accountId, String message) {
        // Save log to DB...
    }
}

3. The Spring Revolution: Lightweight & Modular

While EJB provided a robust solution, early versions were heavy and complex. The Spring Framework emerged with a goal to simplify enterprise development using POJOs (Plain Old Java Objects) and remove the strict requirement for a full-blown application server.

Dependency Injection (DI) & Inversion of Control (IoC)

Spring’s core philosophy is Dependency Injection. Instead of an object creating its own dependencies (tight coupling), an external “injector” (the Spring Container) wires objects together. This makes applications easier to test because you can easily swap real services for mock objects during unit testing.

Configuration Evolution:

Spring configuration has evolved from XML files to Annotations, and finally to pure Java classes (JavaConfig).

Spring Code Example: JavaConfig & DI

Let’s look at a payment processing system configured with Spring. We use @Configuration to define our beans and @Autowired (or constructor injection) to wire them18181818.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// The Service Component
@Service // Stereotype annotation identifying a Spring Bean
public class PaymentProcessor {
    
    private final FraudDetector fraudDetector;

    // Constructor Injection: Dependencies are passed in 
    @Autowired 
    public PaymentProcessor(FraudDetector fraudDetector) {
        this.fraudDetector = fraudDetector;
    }

    public void process(Payment payment) {
        if (fraudDetector.isSafe(payment)) {
            // Process payment...
        }
    }
}

// The Java Configuration (Replacing XML)
@Configuration
public class AppConfig {

    @Bean // Explicitly defining a bean
    public FraudDetector fraudDetector() {
        return new AdvancedFraudDetector(); 
    }
}

In this model, PaymentProcessor knows nothing about how FraudDetector is created. This is Inversion of Control in action.

4. Modern Data Access: Spring Data & JPA

Spring dramatically simplifies data access. While you can inject a raw EntityManager (just like in EJB), the Spring Data module takes this a step further with the Repository pattern.

The Repository Abstraction

With Spring Data, you define an interface extending JpaRepository. You do not write the implementation class; Spring generates the proxy implementation at runtime. It even derives SQL queries directly from method names.

Scenario: A user management system.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;

// We only define the interface
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
    
    // Spring Data automatically generates the query:
    // "SELECT u FROM User u WHERE u.lastName = ?1" 
    List<User> findByLastName(String lastName);
    
    // Supports complex logic purely by naming convention
    // "SELECT u FROM User u WHERE u.email = ?1 AND u.active = true"
    User findByEmailAndActiveTrue(String email);
}

This reduces boilerplate code significantly compared to writing explicit DAO (Data Access Object) implementations.

5. Unified Transaction Management in Spring

Spring provides a unified abstraction for transaction management, meaning your code looks the same whether you are using local JDBC transactions or global JTA transactions.

Similar to EJB, Spring supports declarative transactions via the @Transactional annotation.

Key Differences from EJB:

  1. Default Behavior: In Spring, transactions are not enabled by default; you must explicitly annotate methods or classes.
  2. Flexibility: You can configure the transaction manager (e.g., JpaTransactionManager) to suit your persistence technology.

Example:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class OrderService {

    private final OrderRepository repo;

    public OrderService(OrderRepository repo) { this.repo = repo; }

    // Declarative transaction definition 
    @Transactional(rollbackFor = Exception.class, timeout = 30) 
    public void placeOrder(Order order) throws Exception {
        repo.save(order);
        // If an exception occurs here, the save is rolled back.
        // Spring defaults to rolling back only on RuntimeException, 
        // but we overrode it above to include checked Exceptions.
    }
}

Conclusion

Both EJB and Spring have shaped the landscape of modern Java development. EJB introduced the powerful concepts of container-managed services and layered architecture, while Spring democratized these features, making them more lightweight, testable, and flexible through Dependency Injection and modular design.

Whether you are maintaining legacy EJB systems or building greenfield Spring Boot microservices, the underlying principles—implicit middleware, transactional integrity, and separation of concerns—remain constant.


This blog post is based on my technical interpretation of educational materials provided by Imre Gábor, BME JUT.

This post is licensed under CC BY 4.0 by the author.