In an earlier post I looked at Solon's SnEL engine as a standalone, portable filter DSL — the trick where one expression string gets rewritten into Redis / Milvus / Qdrant filter syntax. A few readers asked the obvious follow-up: that's nice for a library, but where does the expression engine actually show up when I'm running a normal Solon app?
The answer is: almost everywhere the container touches a string that might need to be resolved at runtime. Solon threads SnEL through configuration, dependency injection, conditional beans, method caching, dynamic data sources, and even validation messages. This post is a tour of those integration points, with the exact source paths so you can go read the wiring yourself.
APIs below are checked against the
soloncore andsolon-projectssource. The relevant helper isorg.noear.solon.core.util.SnelUtil.
The dispatcher: #{...} vs ${...}
Almost every container-side use goes through one small helper, SnelUtil.evalTmpl. It's worth reading because it explains a naming convention you'll see all over Solon:
// org.noear.solon.core.util.SnelUtil
public static String evalTmpl(String tmpl, Map model) {
if (tmpl.indexOf("#{") >= 0) {
return SnEL.evalTmpl(tmpl, model); // new: full SnEL sub-expression
} else if (tmpl.indexOf("${") >= 0) {
return TmplUtil.parse(tmpl, model); // legacy: simple property template
} else {
return tmpl; // no marker: return as-is
}
}
So the rule of thumb across the whole framework:
-
${...}— a property placeholder. Pull a value from config by key, optionally${key:default}. -
#{...}— a SnEL evaluation placeholder. The braces contain a real sub-expression that gets evaluated.
Keep that distinction in your head; it's the key to reading everything below.
1. Resolving placeholders anywhere
AppContext exposes the resolver directly:
// org.noear.solon.core.AppContext
public String resolvePlaceholders(String expr) {
return SnelUtil.evalTmpl(expr, cfg());
}
cfg() is the application configuration (app.yml / properties). So anywhere you have the context, you can expand a template against config:
String url = Solon.context()
.resolvePlaceholders("jdbc:mysql://${db.host:localhost}:${db.port:3306}/app");
// or evaluate a real expression against config
String banner = Solon.context()
.resolvePlaceholders("app is #{'v' + ${app.version:1.0}}");
2. Injection: config values and evaluated values
@Inject is where the ${} / #{} split really pays off. The container inspects the string and branches (see BeanContainer):
// @Inject("${xxx}") or @Inject("${xxx:def}") — inject a config value (single value)
if (name.startsWith("${")) {
String name2 = findConfigKey(name);
beanInjectConfig(vh, name2, required);
// ... plus auto-refresh binding when the config key changes
return;
}
// @Inject("#{...}") — evaluate a SnEL template, then convert to the field type
if (name.startsWith("#{")) {
String val = SnelUtil.evalTmpl(name, cfg());
Object val2 = ConvertUtil.to(vh.getType(), vh.getGenericType(), val);
vh.setValue(val2);
return;
}
In practice:
@Component
public class MyService {
// classic config injection, with default + hot-refresh on field injection
@Inject("${app.title:Solon}")
String title;
// evaluated expression, converted to the target type
@Inject("#{${server.port:8080} + 1}")
int adminPort;
}
Note the nesting in the second one: ${server.port:8080} is a property reference inside a #{...} expression, so the config value is pulled first and then the arithmetic runs. And because ${} field injection registers a config-change listener, those values can hot-refresh when the config source updates.
3. Conditional beans with @Condition(onExpression=...)
This is my favorite container-side use. @Condition decides whether a bean/configuration is created at all, and onExpression is a SnEL expression evaluated against config:
// org.noear.solon.core.util.ConditionUtil
private static boolean testExpression(AppContext context, String expr) {
Object val = SnelUtil.eval(expr, context.cfg());
if (val instanceof Boolean) return (Boolean) val; // true/false directly
if (val instanceof String) return Assert.isNotEmpty((String) val); // non-empty string
return val != null; // otherwise: non-null
}
The annotation documents the intended shape (note it uses ${} property refs inside the expression):
@Condition(onExpression = "${env} == 'pro'")
@Configuration
public class ProdOnlyConfig {
@Bean
public MetricsReporter reporter() { ... }
}
// combine conditions
@Condition(onExpression = "${feature.cache} == 'on' && ${env} != 'test'")
@Component
public class CacheWarmup { ... }
Because the evaluation result is coerced sensibly (Boolean → itself, String → non-empty, other → non-null), you can also write @Condition(onExpression = "${some.key}") to mean "only if this key has a value." @Condition also has the type-safe onClass / onBean / onMissingBean knobs — onExpression is the escape hatch for config-driven logic. (The older onProperty attribute is deprecated in 3.6 in favor of onExpression.)
4. Method caching: templating keys from arguments
Solon's declarative cache (solon-data) builds cache keys and tags from a template that can reference method arguments by name. The interceptor runs each attribute through SnelUtil.evalTmpl with an Invocation-backed context:
// org.noear.solon.data.cache.CacheExecutorImp (abridged)
String key = anno.key();
if (Utils.isEmpty(key)) {
key = InvKeys.buildByInv(inv); // auto key from args when none given
}
key = SnelUtil.evalTmpl(key, inv); // expand #{...} against method args (+ result)
The context that backs inv exposes each argument by its @Param name, plus a special result key for the return value (used by @CachePut):
// org.noear.solon.core.util.SnelUtil.InvocationContext#apply
if (inv.result() != null && "result".equals(key)) {
return inv.result();
}
Object rst = inv.argsAsMap().get(key); // argument-by-name
So the real usage looks like this (straight from the framework's own test service):
@Component
public class UserService {
@Cache(key = "#{id}", seconds = 30)
public User getUser(@Param("id") String id) { ... }
@CachePut(key = "user_#{user.id}")
public User update(User user) { ... }
@CacheRemove(keys = "user_#{id}")
public void delete(@Param("id") String id) { ... }
}
tags works the same way and supports multiple comma-separated values, each templated. The Cache annotation's own Javadoc gives the canonical example: user_#{user_id}.
5. Dynamic data source routing with @DynamicDs
The dynamic data source interceptor picks a datasource name by evaluating its template — so you can route by a method argument at call time:
// org.noear.solon.data.dynamicds.DynamicDsInterceptor
String dsName = SnelUtil.evalTmpl(anno.value(), inv);
return DynamicDsKey.with(dsName, inv::invoke);
@Component
public class OrderDao {
// route to a datasource chosen by the tenant argument
@DynamicDs("#{tenant}_db")
public Order load(@Param("tenant") String tenant, long id) { ... }
}
Same Invocation context as caching, so argument names are in scope.
6. Validation messages: a per-instance SnelParser
Not everything uses the static SnEL facade. When you need a configured parser — a custom marker, a bounded cache — you instantiate SnelParser directly. The i18n validation failure handler does exactly this:
// org.noear.solon.validation.ValidatorFailureHandlerI18n
private final SnelParser SNEL;
// custom marker '#','{' and a cache capacity
SNEL = new SnelParser(cacheCapacity, '#', '{');
// ...
if (SNEL.hasMarker(msg)) {
msg = SNEL.forTmpl()
.parse(msg)
.eval(key -> I18nUtil.getMessage(ctx, key.toString()));
}
Here the "variable lookup" isn't a map at all — it's a lambda that resolves each key through the i18n message bundle. That's the general shape of SnEL: the context is any Function<String, Object>, so you can back it with config, a POJO, method arguments, or a message catalog.
Why do it this way?
Two things stand out once you see all six use sites together.
First, one engine, consistent semantics. Config injection, conditional beans, cache keys, and datasource routing all share the same ${}/#{} convention and the same evaluator. Learn it once and it reads the same everywhere.
Second, safe by omission. SnEL has no object instantiation and no control flow, so exposing it to app.yml or annotation strings doesn't open a scripting hole. It's an evaluator, not a scripting language — which is precisely why the container can lean on it so heavily.
If you came from the vector-DB filter angle in the last post, this is the other half of the picture: the same tiny 40KB engine that rewrites database filters is also the quiet workhorse behind Solon's configuration and DI.
- solon-expression: https://github.com/opensolon/solon-expression
- Solon: https://github.com/opensolon/solon
- SnEL docs: https://solon.noear.org/article/learn-solon-snel
Have you wired an expression engine into your own container or config layer? I'm curious how you kept it from turning into a security footgun.
Top comments (0)