Foundation · Understand

Production Implementation: Inspectable Spring Wiring

Assemble a real Controller -> Service -> Repository slice with constructor injection, @Configuration beans, and lifecycle hooks you can observe at startup.

Primary source: Spring Framework 6.2 Reference: The IoC Container

45 min

Lesson 14 of 14

1d streak

The Architectural Question

Tutorial wiring hides the decisions production code must make: which dependencies are required, where interfaces earn their keep, and what the container actually does at startup.

What this guide proves

  • Wire collaborators with constructors instead of field injection
  • Register third-party types through @Configuration and @Bean
  • Observe bean creation order, proxy wrapping, and shutdown callbacks

Capstone: compose one notification use case

This lesson is an integration exercise, not a second introduction to IoC. Use the earlier lessons as reference: @Service and @Bean provide definitions, constructor injection resolves collaborators, scopes define identity, lifecycle callbacks define readiness, and proxies apply cross-cutting behavior. Here you assemble those decisions into one small notification feature and verify the boundaries that matter in production.

  1. 01Choose the application boundary: NotificationService owns the use case and MessageSender is its port.
  2. 02Choose the registration boundary: component scanning finds the service while Java configuration supplies the adapter.
  3. 03Choose the runtime boundary: the service is a singleton, its dependency is constructor-injected, and any advised call enters through the managed reference.
  4. 04Choose the proof: test the use case without Spring, then start a focused context and verify the intended beans and collaborators exist.

Use the earlier lessons, do not reread them here

If you cannot explain one of those choices, return to the specific lesson named in the roadmap. This capstone should reveal an integration mistake, not repeat every definition from the module.

Implement the composition root

Apply the module rules to a production-shaped boundary. The service owns the use-case behavior; the composition root chooses the MessageSender implementation. The service does not construct infrastructure, and its required collaborator is an immutable constructor dependency.

NotificationService.java
package com.example.core.notification;

import org.springframework.stereotype.Service;
import java.util.Objects;

@Service
public class NotificationService {
    private final MessageSender sender;

    public NotificationService(MessageSender sender) {
        this.sender = Objects.requireNonNull(sender, "sender must not be null");
    }

    public void sendWelcome(String recipient) {
        sender.send("Welcome to the platform", recipient);
    }
}

Fail-fast assembly

If MessageSender is missing from the ApplicationContext, Spring aborts startup immediately with UnsatisfiedDependencyException. This guarantees the service cannot run in an uninitialized, half-wired state.

Break it deliberately: The bypassed proxy test verification

Verify in a real test that direct instantiation bypasses the CGLIB proxy while container injection preserves it:

ProxyVerificationTest.java
@SpringBootTest
class ProxyVerificationTest {

    @Autowired
    private NotificationService containerManagedService;

    @Test
    void testProxyIntegrity() {
        // 1. Container-managed service has collaborators wired
        assertThat(containerManagedService).isNotNull();

        // 2. Direct instantiation bypasses container
        NotificationService unmanaged = new NotificationService(null);
        assertThatThrownBy(() -> unmanaged.sendWelcome("user@example.com"))
            .isInstanceOf(NullPointerException.class);
    }
}

Check your understanding

Active Recall Checkpoint

What happens when a method annotated with @Transactional is called from another method within the same bean (self-invocation)?

Hint: Remember which object holds the interceptor advice.

Show answer & explanation

The self-invocation calls the method on this directly, completely bypassing the dynamic proxy. Consequently, transaction advice never intercepts the call, and no transaction is opened or committed.

Working Implementation

Code Walkthrough

Build a notification pipeline where swapping one @Bean changes the delivery adapter without touching any consumer.

InspectableWiringProductionLab.java
package com.example.core;

import jakarta.annotation.PostConstruct;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.aop.support.AopUtils;
import org.springframework.beans.factory.config.BeanPostProcessor;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.stereotype.Component;
import org.springframework.stereotype.Service;

@SpringBootApplication
public class InspectableWiringProductionLab {
    private static final Logger log = LoggerFactory.getLogger(InspectableWiringProductionLab.class);

    public interface TaskAuditClient {
        void logAction(String action);
    }

    @Service
    public static class TaskService {
        private final TaskAuditClient auditClient;
        public TaskService(TaskAuditClient auditClient) {
            this.auditClient = auditClient;
        }

        @PostConstruct
        void init() {
            log.info("TaskService initialized with auditClient: {}", auditClient.getClass().getSimpleName());
        }

        public void completeTask(long id) {
            auditClient.logAction("Task " + id + " marked complete");
        }
    }

    @Bean
    TaskAuditClient taskAuditClient() {
        return action -> log.info("[AUDIT] {}", action);
    }

    @Component
    static class ProxyAuditProbe implements BeanPostProcessor {
        @Override
        public Object postProcessAfterInitialization(Object bean, String beanName) {
            if (AopUtils.isAopProxy(bean)) {
                log.info("Proxy detected on bean '{}' (class: {})", beanName, bean.getClass().getName());
            }
            return bean;
        }
    }

    @Bean
    CommandLineRunner run(TaskService service) {
        return args -> service.completeTask(42L);
    }

    public static void main(String[] args) {
        SpringApplication.run(InspectableWiringProductionLab.class, args);
    }
}
Combines immutable constructor injection, @Configuration third-party client registration, and lifecycle initialization.
A BeanPostProcessor probe inspects live beans to verify dynamic proxy wrapping during startup.
The entire service layer remains 100% testable in plain unit tests without booting the Spring container.

Core Spring Implementation

Inspectable Core Spring wiring lab

Assemble one notification feature end to end: constructor-injected collaborators, visible bean lifecycle, proxy boundaries, and a documented circular-dependency redesign.

Component Boundaries

  1. 01The ApplicationContext instantiates and wires every collaborator; application classes never construct services with new.
  2. 02@Transactional and @Cacheable advice attach through CGLIB proxies built by BeanPostProcessors, so self-invocation skips advice.
  3. 03Dependency cycles fail at startup with BeanCurrentlyInCreationException; orchestration belongs in a single coordinator class.

Required Evidence

  • A framework-free unit test proves NotificationService behavior with one fake MessageSender.
  • The startup log separates proxied beans, such as transactional services, from raw instances.
  • A failing-cycle test case records the original exception message and the coordinator refactor that replaced the cycle.

Implementation sequence

Step-by-Step Integration

Follow these structured steps to inject Core Spring functionality cleanly across your boundaries.

01

Expose dependencies through constructors

NotificationService.java

One final-field constructor fails fast at startup with UnsatisfiedDependencyException when a bean is missing, and lets unit tests substitute fakes without booting Spring.

NotificationService.java
@Service
class NotificationService {
    private final MessageSender sender;

    NotificationService(MessageSender sender) {
        this.sender = sender;
    }

    void sendWelcome(User user) {
        sender.send("welcome", user.email());
    }
}

Verify Configuration

NotificationServiceTest constructs the service with a fake MessageSender and asserts sendWelcome dispatches to user.email() with no ApplicationContext started.

02

Log proxy wrapping during startup

LifecycleProbe.java

Beans wrapped for transactions or caching become CGLIB subclasses; calling methods via this bypasses that proxy and silently drops the advice.

LifecycleProbe.java
@Component
class LifecycleProbe implements BeanPostProcessor {
    @Override
    public Object postProcessAfterInitialization(Object bean, String name) {
        if (AopUtils.isAopProxy(bean)) {
            log.info("{} is proxied", name);
        }
        return bean;
    }
}

Verify Configuration

Startup output contains one INFO line per proxied bean, for example orderService is proxied, while non-advised components print no marker.

03

Break the circular dependency by design

NotificationCoordinator.java

Adding @Lazy or switching to field injection silences BeanCurrentlyInCreationException without fixing the tangled responsibilities that produced the cycle.

NotificationCoordinator.java
@Service
class NotificationCoordinator {
    private final UserRegistration registration;
    private final NotificationService notifications;

    void register(RegisterUser command) {
        User user = registration.create(command);
        notifications.sendWelcome(user);
    }
}

Verify Configuration

Introducing a two-way constructor dependency reproduces the BeanCurrentlyInCreationException in CI, and the coordinator refactor brings contextLoads() back to green with no cycle warnings.

Synthesis & Production Verification

Production Acceptance Criteria

  • Every required collaborator arrives through an immutable constructor parameter
  • A third-party client class is registered via @Bean with its configuration in one place
  • Startup logs show instantiation order and @PreDestroy runs on shutdown

Architectural & Technical Review

Core Interview Questions

Q01What should the Core Spring capstone verify after the application context starts?Show answer

It should verify that the intended service and adapter are registered, the service has its required collaborator, and the use case can be exercised through the managed reference. Proxy and post-processor mechanics belong to their dedicated lessons.

Q02Why should the capstone use a port for MessageSender instead of a concrete provider?Show answer

The port keeps the use case independent of infrastructure and lets a unit test provide a fake sender while the composition root selects the production adapter.

Q03Which evidence separates a plain unit test from a Spring context test in this capstone?Show answer

The unit test constructs NotificationService with a fake MessageSender and proves business behavior without Spring. The context test proves registration and wiring. Keeping those proofs separate shows whether a failure belongs to application behavior or container assembly.

Production Pitfalls

Critical Anti-Patterns

  • Pitfall: Treating the capstone as a new definition lesson — The capstone should apply the module concepts and expose integration mistakes. Return to the dedicated proxy or circular-dependency lesson when that mechanism is the subject of the question.
  • Pitfall: Putting provider choices inside the use case — Constructing an SMTP, SMS, or push client inside NotificationService couples policy to infrastructure. Keep the provider selection in configuration and inject the MessageSender port.

Module Certification Quiz

Prove Module Mastery

Comprehensive question set covering all lessons in this module.

Take Module Quiz

Primary Documentation & Specifications

Supplementary Module Resources