DEV Community

Solon Framework
Solon Framework

Posted on

Solon's Application Lifecycle: 12 Timing Points, LifecycleBean, EventBus and Plugin SPI

Most framework bugs I have chased in Java web apps were not logic bugs. They were timing bugs: a bean that read config before the config was loaded, a listener that subscribed after the event had already fired, a connection pool that closed while requests were still draining.

Solon is explicit about this. The docs lay out the whole application lifecycle as a fixed set of timing points, and every extension mechanism in the framework hangs off one of them. Once you can name the points, "when does my code run" stops being guesswork.

Here is the model, with the four hooks you actually write against.

The lifecycle, counted

The official breakdown is one init callback + six application events + three plugin timing points + two container timing points.

The init callback is the lambda you pass to Solon.start:

import org.noear.solon.Solon;
import org.noear.solon.annotation.SolonMain;

@SolonMain
public class App {
    public static void main(String[] args) {
        Solon.start(App.class, args, app -> {
            // application init timing point
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

The six application events, in order:

Event Meaning Note
AppInitEndEvent init finished manual subscription only
AppPluginLoadEndEvent plugin loading finished manual subscription only
AppBeanLoadEndEvent bean scan finished
AppLoadEndEvent startup finished
AppPrestopEndEvent pre-stop
AppStopEndEvent stopped since v2.1.0

All of them live in org.noear.solon.core.event.

Two warnings from the docs are worth repeating, because both are easy to trip over:

Do not block the startup thread. The app only runs normally after startup completes. A blocking call in an init hook will hang the whole boot.

Events before AppBeanLoadEndEvent must be subscribed before startup. By the time class scanning happens, AppInitEndEvent and AppPluginLoadEndEvent have already fired. An annotated listener discovered during the scan is simply too late. For those two you subscribe manually:

import org.noear.solon.Solon;
import org.noear.solon.annotation.SolonMain;
import org.noear.solon.core.event.AppInitEndEvent;

@SolonMain
public class App {
    public static void main(String[] args) {
        Solon.start(App.class, args, app -> {
            app.onEvent(AppInitEndEvent.class, e -> {
                //...
            });
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

For anything from AppBeanLoadEndEvent onward, the annotated form works:

import org.noear.solon.annotation.Component;
import org.noear.solon.core.event.AppLoadEndEvent;
import org.noear.solon.core.event.EventListener;

@Component
public class AppLoadEndEventListener implements EventListener<AppLoadEndEvent> {
    @Override
    public void onEvent(AppLoadEndEvent event) throws Throwable {
        // event.app();
    }
}
Enter fullscreen mode Exit fullscreen mode

That "subscribe early or miss it" rule is the whole reason the event list is worth memorizing. It is not a quirk, it is a consequence of the container scanning classes exactly once.

LifecycleBean: four methods, two annotations

For ordinary beans you rarely need raw events. LifecycleBean binds your bean to the container's start and stop:

Interface method Annotation Runs at
start() @Init AppContext::start()
postStart() same, later half (since v2.9)
preStop() AppContext::preStop() (since v2.9)
stop() @Destroy AppContext::stop() (since v2.2.0)
import org.noear.solon.annotation.Component;
import org.noear.solon.core.bean.LifecycleBean;

@Component
public class DemoCom implements LifecycleBean {
    @Override
    public void start() {
        // bean scan is done here, do initialization
    }

    @Override
    public void postStart() {
        // start remote services; do NOT create new managed beans here
    }

    @Override
    public void preStop() {
        // deregister from discovery, etc.
    }

    @Override
    public void stop() {
        // release local resources
    }
}
Enter fullscreen mode Exit fullscreen mode

If you only need start(), the annotation is shorter:

import org.noear.solon.annotation.Component;
import org.noear.solon.annotation.Init;

@Component
public class Demo {
    @Init
    public void init() { // any no-arg method name
    }
}
Enter fullscreen mode Exit fullscreen mode

Same for @Destroy in place of stop().

Three constraints that matter in real code:

The constructor is the wrong place for initialization. At ::new() the bean has been constructed but is not yet registered in the container, and injected fields are not populated. Use constructor-parameter injection, or @Init.

postStart() cannot create managed beans. It is the tail end of startup, meant for kicking off tasks and network listeners.

LifecycleBean only applies to singletons. For non-singletons, only the first instance produced during the scan is managed; the lifecycle of other instances is on you.

Ordering without ceremony

Since v2.2.8, LifecycleBean instances are ordered automatically, and the ordering comes from injection dependencies. If Bean2 injects Bean1, then Bean1.start() runs first:

@Component
public class Bean1 implements LifecycleBean {
    @Override
    public void start() {
        // db1 init ...
    }

    public void func1() {
        // db1 call
    }
}

@Component
public class Bean2 implements LifecycleBean {
    @Inject
    Bean1 bean1;

    @Override
    public void start() {
        bean1.func1();
    }
}
Enter fullscreen mode Exit fullscreen mode

The docs call out the useful trick here: even when two beans have no natural dependency, adding an injection creates one, and ordering follows for free.

The flip side is that mutual injection between two LifecycleBeans makes the sort unsolvable and you get a circular-dependency error. Two ways out: drop the mutual dependency, or pin the order explicitly with @Component(index = 1) / @Component(index = 2).

If a dependency simply is not ready during start() — some beans are only produced at AppContext.start() time — move that work to an AppLoadEndEvent listener instead.

EventBus: strongly typed, synchronous, transaction-friendly

The lifecycle events above ride on the same in-process bus you can use for your own events. Three properties define it: strongly typed events, publish/subscribe, and synchronous dispatch that propagates exceptions — which is what makes transaction rollback work across a publish.

@Getter
public class HelloEvent {
    private String name;
    public HelloEvent(String name) {
        this.name = name;
    }
}
Enter fullscreen mode Exit fullscreen mode

Subscribe either way:

import org.noear.solon.annotation.Component;
import org.noear.solon.core.event.EventBus;
import org.noear.solon.core.event.EventListener;

@Component
public class HelloEventListener implements EventListener<HelloEvent> {
    @Override
    public void onEvent(HelloEvent event) throws Throwable {
        System.out.println(event.getName());
    }
}

// or manually
EventBus.subscribe(HelloEvent.class, event -> {
    System.out.println(event.getName());
});
Enter fullscreen mode Exit fullscreen mode

Publish:

@Component
public class DemoService {
    public void hello(String name) {
        EventBus.publish(new HelloEvent(name));           // synchronous
        // EventBus.publishAsync(new HelloEvent(name));    // generally not recommended
    }
}
Enter fullscreen mode Exit fullscreen mode

The docs are direct about publishAsync: generally not recommended, because it cannot propagate exceptions and therefore cannot participate in transaction propagation. If you want a topic-based bus rather than a type-based one, the docs point at DamiBus instead of stretching EventBus to fit.

Plugin: the same lifecycle, one level up

A Plugin is a module-level participant in the application lifecycle. The interface is three methods:

public interface Plugin {
    void start(AppContext context) throws Throwable;
    default void preStop() throws Throwable {}
    default void stop() throws Throwable {}
}
Enter fullscreen mode Exit fullscreen mode

Plugin start runs after application init completes — before bean scanning, which is exactly why plugins can register interceptors and extensions that the scan will then honor. preStop runs before stop; with safe-stop enabled there is a gap of a few seconds between them. Container start runs after the scan finishes, and container stop runs after plugin stop.

Registration is declarative, close in spirit to Spring Factories or Java SPI. Put the implementation in an integration package, name it XxxSolonPlugin, and keep it free of injection:

package demo.integration;

public class DemoSolonPlugin implements Plugin {
    @Override
    public void start(AppContext context) {
        // plugin starting...
    }

    @Override
    public void preStop() throws Throwable {
    }

    @Override
    public void stop() {
    }
}
Enter fullscreen mode Exit fullscreen mode

Then declare it in a properties file whose name must be globally unique — using the package name is the recommended convention:

META-INF/solon/{packname}.properties
Enter fullscreen mode Exit fullscreen mode
solon.plugin={PluginImpl}
solon.plugin.priority=1
Enter fullscreen mode Exit fullscreen mode

priority is higher-wins, default 0. At startup Solon scans every .properties under META-INF/solon/, collects the plugins, and sorts them.

To drop a plugin that arrived through a transitive dependency, either configure it out:

solon.plugin.exclude:
  - "{PluginImpl}"
Enter fullscreen mode Exit fullscreen mode

or exclude it in code:

Solon.start(App.class, args, app -> {
    app.pluginExclude(PluginImpl.class);
});
Enter fullscreen mode Exit fullscreen mode

Naming tells you where a plugin came from: solon-* is internal architecture, *-solon-plugin is an external adapter, with *-solon-ai-plugin and *-solon-cloud-plugin for the AI and Cloud interface adapters.

The framework's own solon-data is the compact example. Implementation at org.noear.solon.data.integration.DataSolonPlugin:

public class DataSolonPlugin implements Plugin {
    @Override
    public void start(AppContext context) {
        if (Solon.app().enableTransaction()) {
            context.beanInterceptorAdd(Tran.class, new TranInterceptor(), 120);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

declared in META-INF/solon/solon.data.properties:

solon.plugin=org.noear.solon.data.integration.DataSolonPlugin
solon.plugin.priority=3
Enter fullscreen mode Exit fullscreen mode

That is the entire mechanism behind transaction support: one plugin, registered at a known timing point, adding an interceptor before beans are scanned. Beyond this there are two further layers in the same series — E-SPI for out-of-package extension and H-SPI for hot plug/unplug management — worth knowing exist when you get to modular deployments.

How I decide which hook to use

Working backwards from the failure mode is faster than memorizing the table:

  • Need injected fields available? Not the constructor. Use @Init.
  • Need to start a listener or scheduled task? postStart().
  • Need to deregister before shutdown drains? preStop().
  • Need to release local resources? @Destroy / stop().
  • Need something to run when everything is up, including beans created during startup? AppLoadEndEvent.
  • Need to react before the bean scan? Manual subscription in the Solon.start lambda.
  • Need to ship the behavior as a reusable module across projects? A Plugin plus one properties file.

The ordering rule is the part I would keep in muscle memory: dependencies define order, and if you need order without a dependency, injection is the cheapest way to declare it.

Docs: https://solon.noear.org/article/240 (lifecycle), https://solon.noear.org/article/480 (LifecycleBean), https://solon.noear.org/article/264 (EventBus), https://solon.noear.org/article/58 (Plugin SPI). Version basis: Solon v4.0.4.

Top comments (0)