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.