As a best-selling author, I invite you to explore my books on Amazon. Don't forget to follow me on Medium and show your support. Thank you! Your support means the world!
When I first started working with the Spring Framework, everything was about annotations. I would sprinkle @Component, @Service, and @Autowired throughout my code, and like magic, Spring would wire it all together. It worked, but sometimes it felt like I had handed over control to a system I didn't fully understand. Where did this bean come from? Why is it being created now? For a long time, this was just how Spring worked.
Then, a different way of building applications emerged. Instead of telling Spring to scan my classes and figure things out, I could tell it exactly what to do, line by line, in plain Java code. This is often called functional bean registration or functional configuration. It’s a shift from a declarative style—where you state what you want—to an imperative style, where you write the steps to get there. For many developers, including myself, this change brings back a sense of clarity and direct control over the application's structure.
Let me show you what I mean. The heart of this approach is the ApplicationContextInitializer. You create a class that implements this interface. Its job is to receive a blank canvas—a GenericApplicationContext—and paint your application onto it by registering beans one at a time.
Here’s a basic example. Instead of annotating a DataSource with @Bean in a configuration class, I would write it out explicitly.
public class MyAppInitializer implements ApplicationContextInitializer<GenericApplicationContext> {
@Override
public void initialize(GenericApplicationContext context) {
// I define and register a DataSource bean
context.registerBean(DataSource.class, () -> {
HikariDataSource dataSource = new HikariDataSource();
dataSource.setJdbcUrl("jdbc:postgresql://localhost/myapp");
dataSource.setUsername("admin");
dataSource.setPassword("secret");
dataSource.setMaximumPoolSize(10);
return dataSource;
});
// Now I register a Repository that needs that DataSource
context.registerBean(UserRepository.class, () -> {
DataSource ds = context.getBean(DataSource.class);
return new JdbcUserRepository(ds);
});
// Finally, a Service that depends on the Repository
context.registerBean(UserService.class, () -> {
UserRepository repo = context.getBean(UserRepository.class);
return new UserService(repo);
});
}
}
To use this, I would bootstrap my Spring application a bit differently, pointing it to my initializer.
public class Application {
public static void main(String[] args) {
SpringApplication app = new SpringApplication(Application.class);
app.addInitializers(new MyAppInitializer());
app.run(args);
}
}
When this runs, there’s no classpath scanning. No reading of @Component annotations. Spring executes my initialize method, and I have a fully defined context. The order of operations is crystal clear because I wrote it. The UserService cannot be created before the UserRepository, and the repository cannot exist without the DataSource. I have defined a direct, linear dependency graph in code.
This explicit style opens the door to several powerful techniques that make application configuration more robust, testable, and adaptable.
The first technique is conditional registration. In the annotation world, I might use @Profile("dev") or @ConditionalOnProperty. With functional beans, I can use simple Java logic. I check a condition, and if it’s true, I register the bean. It’s just an if statement.
Imagine I have a payment service. In a local development environment, I don’t want to call a real credit card processor. I can check an environment variable or property and decide which bean to provide.
@Override
public void initialize(GenericApplicationContext context) {
Environment env = context.getEnvironment();
String environmentName = env.getProperty("app.environment", "development");
if ("production".equals(environmentName)) {
context.registerBean(PaymentProcessor.class, () -> {
String apiKey = env.getProperty("payment.api.key");
return new StripePaymentProcessor(apiKey);
});
} else {
// Use a mock for development and testing
context.registerBean(PaymentProcessor.class, FakePaymentProcessor::new);
}
// Another example: feature flags
boolean newSearchEnabled = Boolean.parseBoolean(
env.getProperty("features.search.v2.enabled", "false")
);
if (newSearchEnabled) {
context.registerBean(SearchService.class, ElasticSearchService::new);
} else {
context.registerBean(SearchService.class, LegacySearchService::new);
}
}
This is incredibly straightforward. The logic lives right where the bean is defined. Anyone reading this code can immediately see the rules governing which implementation is used. There’s no need to hunt down a separate configuration class annotated with @Conditional.
The second technique deals with a classic problem: circular dependencies. With @Autowired on fields or constructors, it’s easy to accidentally create a loop—ServiceA needs ServiceB, and ServiceB needs ServiceA. Spring can sometimes resolve this with tricks, but it’s a design smell and can lead to confusing runtime errors.
Functional registration forces this into the open. You cannot write code that creates an unresolvable loop because you have to fetch beans in a sequence. You have to think about the order of creation and break the cycle. Often, the solution involves restructuring or using setter injection, but you do it consciously.
Let’s say I have an OrderService that sends notifications and a NotificationService that needs to log which order triggered a notification. This is a bit contrived, but it illustrates the point.
@Override
public void initialize(GenericApplicationContext context) {
// Register simple, independent beans first
context.registerBean(EmailGateway.class, SmtpEmailGateway::new);
context.registerBean(Logger.class, Slf4jLogger::new);
// Now, for the circular part, I need to be clever.
// I'll create a shared object to act as a "bridge".
final OrderService[] orderServiceHolder = new OrderService[1];
final NotificationService[] notificationServiceHolder = new NotificationService[1];
// I register NotificationService first, but its constructor
// won't receive the OrderService yet.
context.registerBean(NotificationService.class, () -> {
Logger logger = context.getBean(Logger.class);
NotificationService service = new NotificationService(logger);
notificationServiceHolder[0] = service; // Store for later
return service;
});
// Now I register OrderService, which can get the NotificationService.
context.registerBean(OrderService.class, () -> {
NotificationService ns = context.getBean(NotificationService.class);
EmailGateway gateway = context.getBean(EmailGateway.class);
OrderService service = new OrderService(ns, gateway);
orderServiceHolder[0] = service; // Store for later
return service;
});
// Finally, I inject the OrderService into the NotificationService
// using a setter method I would have defined.
// This happens after both beans are instantiated.
notificationServiceHolder[0].setOrderService(orderServiceHolder[0]);
}
This code is more verbose, but it makes the circular dependency painfully obvious. It often pushes me to refactor and find a better design, like extracting a common EventPublisher that both services can use without directly knowing about each other. The functional style turns a hidden runtime issue into a visible code structure challenge.
The third technique is a major advantage: testability. When beans are defined with annotations, my tests often rely on loading the entire Spring context with specific profiles. This can be slow. With functional beans, my configuration is code. I can create a new, minimal context for each test and register only the beans I need.
In a test, I can replace a real UserRepository that talks to a database with a mock or an in-memory version in just a few lines.
@Test
void testUserServiceWithMock() {
// Create a fresh, empty Spring context
try (GenericApplicationContext testContext = new GenericApplicationContext()) {
// Manually register only the beans needed for this test
testContext.registerBean(UserRepository.class, () -> {
// This is a simple mock implemented as a lambda
return new UserRepository() {
Map<Long, User> fakeDb = new HashMap<>();
@Override
public User findById(Long id) {
return fakeDb.get(id);
}
@Override
public User save(User user) {
fakeDb.put(user.getId(), user);
return user;
}
};
});
testContext.registerBean(UserService.class, () -> {
UserRepository repo = testContext.getBean(UserRepository.class);
return new UserService(repo);
});
// Refresh the context to initialize the beans
testContext.refresh();
// Retrieve and test
UserService service = testContext.getBean(UserService.class);
User testUser = new User(1L, "Test");
service.saveUser(testUser);
User found = service.findUser(1L);
assertThat(found.getName()).isEqualTo("Test");
}
// The context closes automatically here, cleaning up everything
}
This test is fast. It doesn’t need a properties file, a database, or any external systems. I have complete control over the test environment. I can create a hundred variations of this test, each with slightly different bean configurations, without worrying about context caching or pollution.
The fourth technique is module composition. A large application is built from smaller parts: a data access module, a web security module, a billing module, etc. With functional configuration, I can define each module in its own ApplicationContextInitializer. My main application then becomes an assembly line, combining these independent modules.
This creates fantastic separation of concerns. The data module doesn’t know anything about web security, and the security module doesn’t care where the user data comes from.
Here’s how that can look:
// File: DataModule.java - Handles database and repositories
public class DataModule implements ApplicationContextInitializer<GenericApplicationContext> {
@Override
public void initialize(GenericApplicationContext context) {
context.registerBean(DataSource.class, this::createHikariDataSource);
context.registerBean(JdbcTemplate.class, () ->
new JdbcTemplate(context.getBean(DataSource.class))
);
context.registerBean(UserRepository.class, () ->
new JdbcUserRepository(context.getBean(JdbcTemplate.class))
);
context.registerBean(OrderRepository.class, () ->
new JdbcOrderRepository(context.getBean(JdbcTemplate.class))
);
}
private DataSource createHikariDataSource() {
// ... datasource configuration
}
}
// File: SecurityModule.java - Handles authentication
public class SecurityModule implements ApplicationContextInitializer<GenericApplicationContext> {
@Override
public void initialize(GenericApplicationContext context) {
context.registerBean(SecurityFilter.class, JwtAuthFilter::new);
context.registerBean(PasswordEncoder.class, BCryptPasswordEncoder::new);
// It expects a UserRepository bean to exist, provided by another module.
context.registerBean(UserDetailsService.class, () ->
new AppUserDetailsService(context.getBean(UserRepository.class))
);
}
}
// File: MainApplication.java
public class MainApplication {
public static void main(String[] args) {
SpringApplication app = new SpringApplication();
// Compose the application from these functional modules
app.addInitializers(new DataModule(), new SecurityModule());
// Run it
app.run(args);
}
}
If I need to reuse the DataModule in a different application—like a batch processor instead of a web app—I can. I just combine it with a BatchModule instead of a SecurityModule. The modules are loosely coupled building blocks.
The fifth technique is less about a specific pattern and more about a mindset: lazy and programmatic bean definition. Because I’m writing Java code, I can use loops, collections, and factories to register beans in ways that would be awkward or impossible with annotations.
For instance, imagine I need to register a series of similar validator beans based on a configuration list.
@Override
public void initialize(GenericApplicationContext context) {
Environment env = context.getEnvironment();
// Read a comma-separated list of country codes
String countries = env.getProperty("supported.countries", "US,CA,UK");
List<String> countryList = Arrays.asList(countries.split(","));
// Register a dedicated validator bean for each country
for (String countryCode : countryList) {
String beanName = countryCode.toLowerCase() + "AddressValidator";
context.registerBean(beanName, AddressValidator.class,
() -> new CountrySpecificAddressValidator(countryCode)
);
}
// Register a main service that can use all of them
context.registerBean(ValidationService.class, () -> {
// Collect all beans of type AddressValidator
Map<String, AddressValidator> validators = context.getBeansOfType(AddressValidator.class);
return new ValidationService(validators);
});
}
This is dynamic bean registration. The number and type of beans are determined at startup based on external configuration. It’s powerful and flexible.
To be clear, this functional approach isn’t a complete replacement for every annotation. In fact, you can mix both styles. A Spring Boot application using @SpringBootApplication can still use an ApplicationContextInitializer to add functional bean definitions on top of the auto-configured ones. This allows for a gradual migration or for using functional techniques only where they provide a clear benefit, like in complex conditional logic or for testing.
The transition can feel a bit strange at first. You trade the concise, declarative nature of @Service for more verbose, explicit code. But in return, you get transparency. You know exactly when and how your beans are created. Your application’s startup process becomes faster because it skips classpath scanning. Your dependency graph is visible in your code editor, not hidden in Spring’s internal metadata.
For me, the biggest win is in understanding. When I open a codebase using functional bean registration, I can follow the thread of creation from the main method down to every service and repository. There’s no magic. It’s just Java code, doing what I told it to do. In complex applications, where the “why” is as important as the “what,” this clarity is invaluable. It turns configuration from a mysterious incantation into a readable, debuggable, and testable part of the application.
📘 Checkout my latest ebook for free on my channel!
Be sure to like, share, comment, and subscribe to the channel!
101 Books
101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.
Check out our book Golang Clean Code available on Amazon.
Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!
Our Creations
Be sure to check out our creations:
Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | Java Elite Dev | Golang Elite Dev | Python Elite Dev | JS Elite Dev | JS Schools
We are on Medium
Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva
Top comments (0)