What Actually Happens During Micronaut Startup

Every Micronaut application starts with a simple line:
Micronaut.build(args)
.start();
Behind that single method call, Micronaut performs a carefully orchestrated startup sequence.
It creates the application context, resolves dependencies, instantiates singleton beans, executes bean lifecycle callbacks, publishes startup events, starts the embedded HTTP server and finally declares the application ready to accept requests.
Most developers know @PostConstruct and perhaps StartupEvent .
Eventually, questions appear.
- When is a bean fully initialized versus just constructed?
- When can a bean be customized before initialization runs?
- When do all singleton beans exist?
- When is the HTTP server already accepting requests?
- Where should application-wide initialization actually live?
To answer those questions, let’s observe the entire startup lifecycle in one small application.
⚡ TL;DR (Quick Recap)
- Micronaut startup consists of a small number of clearly defined lifecycle phases.
- Bean lifecycle listeners execute before and after @PostConstruct, allowing precise customization of bean initialization.
- StartupEvent signals that the application context is ready, while ServerStartupEvent indicates the embedded server is accepting requests.
A single application demonstrating every startup hook
Rather than introducing callbacks one by one, the following example implements nearly every commonly used startup extension point.
Unlike some other dependency injection frameworks, Micronaut keeps its startup API intentionally small: there’s no dedicated startup-runner interface and no pile of competing initialization callbacks. The lifecycle revolves around four things — constructors, bean lifecycle listeners, @PostConstruct, and application events.
To make execution order deterministic, LifecycleBean depends on an ExternalClass produced by a @Factory bean. That forces the factory method to run before LifecycleBean is constructed.
class ExternalClass {}
@Singleton
class LifecycleBean {
private static final Logger log = LoggerFactory.getLogger(LifecycleBean.class);
private final ExternalClass externalClass;
public LifecycleBean(ExternalClass externalClass) {
log.info("03 Constructor");
this.externalClass = externalClass;
}
// @PostConstruct is what gives this bean an "initialization phase."
// BeanInitializedEventListener only fires for beans that have one —
// remove @PostConstruct and step 04 below silently disappears too.
@PostConstruct
void postConstruct() {
log.info("05 @PostConstruct");
}
}
@Singleton
class LifecycleBeanInitializedListener implements BeanInitializedEventListener<LifecycleBean> {
private static final Logger log = LoggerFactory.getLogger(LifecycleBeanInitializedListener.class);
@Override
public LifecycleBean onInitialized(BeanInitializingEvent<LifecycleBean> event) {
log.info("04 BeanInitializedEventListener (fires before @PostConstruct)");
LifecycleBean bean = event.getBean();
// This is the hook's actual purpose: inspect or replace the bean
// BEFORE its own initialization logic runs. A real use case is
// injecting a computed default the constructor had no way to know.
return bean;
}
}
@Singleton
class LifecycleBeanCreatedListener implements BeanCreatedEventListener<LifecycleBean> {
private static final Logger log = LoggerFactory.getLogger(LifecycleBeanCreatedListener.class);
@Override
public LifecycleBean onCreated(BeanCreatedEvent<LifecycleBean> event) {
log.info("06 BeanCreatedEventListener (fires after @PostConstruct)");
// Unlike BeanInitializedEventListener, the bean here is fully
// initialized.
return event.getBean();
}
}
@Singleton
class ContextStartupListener {
private static final Logger log = LoggerFactory.getLogger(ContextStartupListener.class);
@EventListener
void onStartup(StartupEvent event) {
log.info("07 StartupEvent — application context ready");
}
@EventListener
void onServerStartup(ServerStartupEvent event) {
log.info("08 ServerStartupEvent — server accepting requests");
}
}
@Factory
class ExternalClassFactory {
private static final Logger log = LoggerFactory.getLogger(ExternalClassFactory.class);
@Bean
@Singleton
ExternalClass externalClass() {
log.info("02 @Bean factory method (produces ExternalClass, required by LifecycleBean's constructor)");
return new ExternalClass();
}
}
public class StartupApplication {
private static final Logger log = LoggerFactory.getLogger(StartupApplication.class);
public static void main(String[] args) {
log.info("01 Application Context Starting");
Micronaut.build(args).banner(false).eagerInitSingletons(true).start();
log.info("09 Application Ready — running until shutdown");
}
}This example intentionally places every lifecycle callback into a single bean. In a real application, these responsibilities are usually spread across multiple components.
Micronaut startup timeline
Micronaut.build().start()
│
▼
Application Context Starting
│
▼
@Bean factory method
│
▼
Bean creation
├─ Constructor
├─ BeanInitializedEventListener
├─ @PostConstruct
└─ BeanCreatedEventListener
│
▼
StartupEvent
│
▼
ServerStartupEvent
│
▼
Application Ready
Grouped this way, the lifecycle collapses into two phases: bean initialization and application startup.
Phase 1 — Bean initialization
@Bean factory method
│
▼
Bean creation
├─ Constructor
├─ BeanInitializedEventListener
├─ @PostConstruct
└─ BeanCreatedEventListener
@Factory + @Bean.
Sometimes the class you're creating belongs to a third-party library you can't annotate directly. Instead of putting @Singleton on the class itself, expose it as a bean via a @Factory method.
Constructor.
Micronaut instantiates the bean once all constructor dependencies are resolved. Constructor-injected dependencies are safe to use immediately — but as with most DI frameworks, expensive work (I/O, network calls, heavy computation) belongs later in the lifecycle, not in the constructor.
BeanInitializedEventListener.
This fires immediately before the bean's initialization phase, letting infrastructure code inspect, replace, or customize the bean before any @PostConstruct logic runs. The detail that trips people up: this listener only fires if the bean has an initialization phase at all — in practice, that means it has a @PostConstruct method.
Remove @PostConstruct from LifecycleBean and step 04 disappears too. (This behavior isn't extensively documented outside the source and issue tracker — worth confirming against your Micronaut version if it's load-bearing for your design.)
@PostConstruct.
Once dependency injection is complete, Micronaut invokes @PostConstruct. Typical responsibilities: validating configuration, initializing internal state, opening local (non-blocking) resources.
BeanCreatedEventListener.
Finally, Micronaut publishes a BeanCreatedEvent. The bean is now fully initialized and ready for use. This is the hook infrastructure libraries reach for when they need to wrap, decorate or outright replace a bean after initialization — for example, returning a proxy in its place.
Phase 2 — Application startup
StartupEvent → ServerStartupEvent
These two fire close together but mark genuinely different milestones.
StartupEvent.
The application context has finished starting. Every eagerly initialized singleton now exists, and the application is ready for application-wide initialization — as opposed to the per-bean initialization from Phase 1. Typical responsibilities: building registries, discovering beans, initializing plugin systems, coordinating multiple components. If your startup logic depends on several beans existing simultaneously, StartupEvent is almost always the right place for it.
ServerStartupEvent.
Micronaut has now started the embedded HTTP server. By the time this fires: singleton beans are initialized, the application context is running, and the embedded Netty server is actually accepting requests. If an external system (health checks, service discovery, a readiness probe) should only see your application after it's fully operational, this is the event to hook into — not StartupEvent.
Why eagerInitSingletons(true) matters
Micronaut.build(args)
.eagerInitSingletons(true)
.start();
Unlike Spring Boot, Micronaut creates singleton beans lazily by default — a singleton isn’t instantiated until something else actually requires it. Without eager initialization, some beans in this example might never be created and their lifecycle callbacks would simply never fire.
Two caveats worth knowing:
- Micronaut does eagerly initialize certain infrastructure beans — event listeners and beans implementing specific internal framework interfaces — even without this flag. eagerInitSingletons(true) is what forces your application beans to follow suit.
- If you only want eager initialization for one specific bean rather than the entire application, the more idiomatic tool is the @Context annotation on that bean, not the global flag. Reach for eagerInitSingletons(true) for demos and debugging; reach for @Context in production when you need a specific bean created at startup.
In production, Micronaut’s default lazy initialization is one of the reasons it achieves fast startup times and low memory usage — don’t flip this flag on globally without a reason.
Which lifecycle hook should you choose?
Rather than memorizing APIs, ask one simple question:
What must already exist before my code runs?
Use:
- Constructor — object creation only.
- @Factory — initialize third-party classes.
- BeanInitializedEventListener — customize a bean before initialization.
- @PostConstruct — initialize an individual bean.
- BeanCreatedEventListener — customize a fully initialized bean.
- StartupEvent — perform application-wide initialization after all eager singleton beans exist.
- ServerStartupEvent — interact with external systems once the application is fully operational.
Thinking about prerequisites instead of annotations makes selecting the correct hook surprisingly straightforward.
How Micronaut differs
Developers familiar with Spring Boot will immediately notice that Micronaut exposes far fewer startup extension points.
That’s intentional.
Micronaut performs dependency injection during compilation instead of relying on runtime reflection, allowing it to keep the startup lifecycle small and predictable.
Some notable differences include:
- compile-time dependency injection
- lazy singleton creation by default
- bean lifecycle listeners surrounding @PostConstruct
- no InitializingBean
- no @Bean(initMethod)
- no ApplicationRunner or CommandLineRunner
The result is a startup sequence that’s easier to understand while still providing extension points for both bean-specific and application-wide initialization.
Final Takeaways
Micronaut.build().start() looks like a single operation, but it orchestrates a carefully ordered lifecycle. Every callback exists because the application has reached a different level of readiness — from "this one bean exists" to "we're accepting HTTP traffic."
Once you see startup as two phases — bean initialization, then application startup — the whole lifecycle stops being a list of annotations to memorize and becomes a small, reasoned-about sequence: bean-specific work stays in bean lifecycle callbacks, application-wide coordination belongs in StartupEvent and anything touching external systems is safest after ServerStartupEvent.
You can find an example on GitHub.
Originally posted on marconak-matej.medium.com.