Dependency Injection & Bean Lifecycle

Project: A Loosely-Coupled Service Layer

18 min Lesson 10 of 13

Project: A Loosely-Coupled Service Layer

The preceding nine lessons gave you every Spring DI tool in isolation: constructor injection, scopes, lifecycle hooks, @Qualifier, lazy beans, and property binding. This capstone lesson assembles all of them into a single, realistic mini-application — an order-processing backend — and demonstrates the design principles that make the result easy to test, easy to extend, and safe to refactor.

The Goal: Program to Interfaces, Not Implementations

The single most important principle in a DI-driven codebase is to depend on abstractions. Each layer declares what it needs through an interface; Spring wires in the concrete class at runtime. This means you can swap StripePaymentGateway for PayPalPaymentGateway without touching OrderService, and you can inject a FakePaymentGateway in tests without starting a server.

Project Structure

The mini-application has four layers. The dependency arrows only point downward — no layer knows about the layer above it.

  • Controller layer — receives HTTP requests (simulated here with a main method).
  • Service layer — business logic: validates the order, charges the customer, sends a notification.
  • Repository layer — persistence: saves and retrieves orders from the database.
  • Infrastructure layer — external integrations: payment gateway, email sender.

Step 1 — Define the Contracts (Interfaces)

// repository/OrderRepository.java package com.example.orders.repository; import com.example.orders.model.Order; import java.util.Optional; public interface OrderRepository { Order save(Order order); Optional<Order> findById(long id); } // service/PaymentGateway.java package com.example.orders.service; import java.math.BigDecimal; public interface PaymentGateway { String charge(String customerId, BigDecimal amount); // returns transaction ID } // service/NotificationService.java package com.example.orders.service; public interface NotificationService { void sendOrderConfirmation(String email, long orderId); }
Interfaces belong to the domain layer, not the infrastructure layer. PaymentGateway lives in the service package — owned by the business — not in the stripe package that implements it. This is the Dependency Inversion Principle (the D in SOLID): high-level modules define the port; low-level modules provide the adapter.

Step 2 — Write the Implementations

// repository/JdbcOrderRepository.java package com.example.orders.repository; import com.example.orders.model.Order; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Repository; import java.util.Optional; @Repository public class JdbcOrderRepository implements OrderRepository { private final JdbcTemplate jdbc; public JdbcOrderRepository(JdbcTemplate jdbc) { // constructor injection this.jdbc = jdbc; } @Override public Order save(Order order) { jdbc.update( "INSERT INTO orders (customer_id, amount, status) VALUES (?, ?, ?)", order.customerId(), order.amount(), "PENDING" ); return order; } @Override public Optional<Order> findById(long id) { return jdbc.query( "SELECT * FROM orders WHERE id = ?", (rs, n) -> new Order(rs.getLong("id"), rs.getString("customer_id"), rs.getBigDecimal("amount")), id ).stream().findFirst(); } } // infrastructure/StripePaymentGateway.java package com.example.orders.infrastructure; import com.example.orders.service.PaymentGateway; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import java.math.BigDecimal; @Component("stripeGateway") public class StripePaymentGateway implements PaymentGateway { @Value("${stripe.api-key}") private String apiKey; @Override public String charge(String customerId, BigDecimal amount) { // real Stripe SDK call would go here return "txn_" + System.currentTimeMillis(); } } // infrastructure/SmtpNotificationService.java package com.example.orders.infrastructure; import com.example.orders.service.NotificationService; import org.springframework.mail.SimpleMailMessage; import org.springframework.mail.javamail.JavaMailSender; import org.springframework.stereotype.Component; @Component public class SmtpNotificationService implements NotificationService { private final JavaMailSender mailer; public SmtpNotificationService(JavaMailSender mailer) { this.mailer = mailer; } @Override public void sendOrderConfirmation(String email, long orderId) { SimpleMailMessage msg = new SimpleMailMessage(); msg.setTo(email); msg.setSubject("Order #" + orderId + " confirmed"); msg.setText("Thank you for your order."); mailer.send(msg); } }

Step 3 — The Service Layer Wires It All Together

// service/OrderService.java package com.example.orders.service; import com.example.orders.model.Order; import com.example.orders.repository.OrderRepository; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; @Service public class OrderService { private final OrderRepository orderRepository; private final PaymentGateway paymentGateway; private final NotificationService notificationService; // All dependencies injected through the constructor — never via field injection public OrderService( OrderRepository orderRepository, @Qualifier("stripeGateway") PaymentGateway paymentGateway, NotificationService notificationService) { this.orderRepository = orderRepository; this.paymentGateway = paymentGateway; this.notificationService = notificationService; } @Transactional public Order placeOrder(String customerId, String email, BigDecimal amount) { if (amount.signum() <= 0) { throw new IllegalArgumentException("Amount must be positive"); } Order order = orderRepository.save(new Order(null, customerId, amount)); String txnId = paymentGateway.charge(customerId, amount); // persist txnId, update order status … (omitted for brevity) notificationService.sendOrderConfirmation(email, order.id()); return order; } }
Keep services thin on infrastructure details. OrderService does not know that payment is handled by Stripe or that email goes through SMTP. It only knows the contract. This boundary is what makes unit-testing the business logic trivial.

Step 4 — Unit Test with No Spring Context

Because every dependency is injected through the constructor, you can instantiate OrderService in a plain JUnit test — no application context, no database, no network:

// test/OrderServiceTest.java package com.example.orders.service; import com.example.orders.model.Order; import com.example.orders.repository.OrderRepository; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import java.math.BigDecimal; import static org.assertj.core.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class OrderServiceTest { @Mock OrderRepository orderRepository; @Mock PaymentGateway paymentGateway; @Mock NotificationService notificationService; @InjectMocks OrderService orderService; // Mockito calls the constructor @Test void placeOrder_chargesAndNotifies() { Order saved = new Order(1L, "cust-42", new BigDecimal("99.00")); when(orderRepository.save(any())).thenReturn(saved); when(paymentGateway.charge(eq("cust-42"), any())).thenReturn("txn_abc"); orderService.placeOrder("cust-42", "alice@example.com", new BigDecimal("99.00")); verify(paymentGateway).charge(eq("cust-42"), eq(new BigDecimal("99.00"))); verify(notificationService).sendOrderConfirmation("alice@example.com", 1L); } @Test void placeOrder_rejectsZeroAmount() { assertThatThrownBy(() -> orderService.placeOrder("cust-1", "a@b.com", BigDecimal.ZERO)) .isInstanceOf(IllegalArgumentException.class); verifyNoInteractions(paymentGateway, notificationService); } }

Notice that @Mock creates in-memory test doubles. There is no Spring, no Stripe API key, no mail server. The tests run in milliseconds and are completely deterministic.

Step 5 — Integration Test with @SpringBootTest

Once unit tests cover the logic, a separate integration test boots a real (or embedded) context to verify wiring:

@SpringBootTest @ActiveProfiles("test") // loads application-test.properties with H2 + stub beans class OrderServiceIntegrationTest { @Autowired OrderService orderService; @Test void contextLoads_andBeanIsWired() { assertThat(orderService).isNotNull(); } }
# application-test.properties spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1 spring.datasource.driver-class-name=org.h2.Driver stripe.api-key=test_dummy_key spring.mail.host=localhost spring.mail.port=3025

Trade-offs and Decisions Worth Knowing

  • Constructor vs field injection — always prefer constructor injection for mandatory dependencies. Field injection (@Autowired on a field) hides coupling and makes tests harder because you cannot set the field without a Spring context or reflection.
  • @Primary vs @Qualifier — if you have one dominant implementation, mark it @Primary and only apply @Qualifier where you need the alternative. Littering every injection point with @Qualifier is noise.
  • Singleton scope for services — stateless service beans should be singleton (the default). Introducing mutable state in a singleton bean creates subtle concurrency bugs under load.
  • @Transactional on the service, not the repository — the service controls the transaction boundary because it decides which repository operations must succeed or fail together. Putting @Transactional only on the repository gives each SQL statement its own transaction, preventing rollback across operations.
Do not inject the ApplicationContext itself into your service. Calling context.getBean(...) inside a service bypasses DI, hides dependencies, and makes testing painful. If you feel the need to do this, it is almost always a sign the class is doing too much and should be split.

Summary

A loosely-coupled, DI-driven service layer is not just an architectural nicety — it is a force multiplier for testing speed, refactoring confidence, and team velocity. The recipe is consistent: declare interfaces in the domain layer, inject through constructors, let Spring wire the concrete classes, and use Mockito to replace them in unit tests. Every concept introduced in this tutorial — scopes, lifecycle, qualifiers, value injection — exists in service of this one goal: keeping each class focused, replaceable, and verifiable in isolation.