namalzi

← All posts

Digging into Spring's Source 15: SpEL — How an Expression Is Parsed and Evaluated

Spring · Published

How a string like "#{T(java.lang.Math).max(a, 3) + 1}" becomes tokens, then a tree, then a value, followed through the source. How operator precedence is expressed as the call order of parser methods, why there are two evaluation contexts (StandardEvaluationContext and SimpleEvaluationContext), the compiler mode that turns an expression into bytecode, and the path a @Value("#{…}") takes inside the container — explained for someone seeing Spring for the first time.

What this post covers

This is the fifteenth post in the "Digging into Spring's Source" series. Part 14 followed the string "8080" from a configuration file into a bean's int field and asked who does the converting. At the end of that post we saw that doResolveDependency, the method that handles @Value, calls evaluateBeanDefinitionString right before the conversion (DefaultListableBeanFactory line 1682). A plain "8080" passes straight through, but a value wrapped in "#{…}" gets computed there. The tool doing the computing is SpEL (the Spring Expression Language), and this post goes inside it. On the map in Part 1 this is U13 in Phase 3 (foundations).

There are four questions to answer.

Item Details
Source Spring Framework 7.1.0-SNAPSHOT
Commit f447f3c310 (2026-10-01, main branch)
Modules read most spring-expression, spring-context (context.expression), spring-beans (AbstractBeanFactory)
Classes read most Tokenizer, InternalSpelExpressionParser, SpelExpression, ExpressionState, StandardEvaluationContext, SimpleEvaluationContext, SpelCompiler, StandardBeanExpressionResolver

Every line number and every detail in this post refers to that commit. In other versions the line numbers will differ and some details may too. Part 1 explains how to get the source at the same commit.

My study notes go much deeper, line by line. This post keeps only the main thread that someone seeing Spring for the first time can follow. What was left out is named at the end, under "If you want to go deeper".

Words to know first

An expression language lets a program compute, at run time, a small expression written as a string. Something like "a > 5 and a < 10" is a string, not Java code, so it can live in a configuration file or an annotation attribute. SpEL is Spring's expression language, and the whole spring-expression module is it.

A token is a meaningful piece of a string. "max(a, 3)" is nine characters but six tokens: max, (, a, ,, 3, ). The thing that does the cutting is a tokenizer (or lexer).

A tree (AST, abstract syntax tree) is what you get when tokens are grouped according to the grammar. 1 + 2 * 3 is a "plus" node with 1 and a "times" node under it, and 2 and 3 under the "times". The thing that does the grouping is a parser. SpEL's parser is a recursive descent parser: each grammar rule is one method, and methods call methods on the way down. Section 2 is about this.

Evaluation is computing the actual value by walking the tree. In the source it is getValue(). The object that answers questions like "which object's a is this?" or "which class is T(Math)?" during evaluation is the EvaluationContext, and the object an expression refers to by default is the root object.

Compilation here means generating, as bytecode, a Java class that does the same thing the tree does, instead of interpreting the tree. Section 5 covers it.

${…} and #{…} are different things. ${…} is a placeholder resolved by the Environment from Part 12; it is not SpEL. #{…} is SpEL. Section 6 checks in the source which one is handled first.

1. From a string to tokens — Tokenizer

The entry point is SpelExpressionParser. Its Javadoc says "Instances are reusable and thread-safe" (line 28), and all it does is create a fresh InternalSpelExpressionParser for every parse and hand over.

	@Override
	protected SpelExpression doParseExpression(String expressionString, @Nullable ParserContext context) throws ParseException {
		return new InternalSpelExpressionParser(this.configuration).doParseExpression(expressionString, context);
	}

spring-expression/src/main/java/org/springframework/expression/spel/standard/SpelExpressionParser.java, lines 62–65

The inner parser's Javadoc says the opposite: "reusable but are not thread-safe" (line 85). It keeps the token list and "which token am I looking at" (tokenStreamPointer) in fields (lines 113–119). That is why the outer parser makes a new one every time.

A string enters SpelExpressionParser.parseExpression, a fresh InternalSpelExpressionParser is created per parse, Tokenizer.process reads one character at a time into a Token list, eatExpression consumes tokens by recursive descent and builds nodes into a tree, and the tree is wrapped in a SpelExpression. Left-over tokens mean a SpelParseException. Blue boxes are steps, grey boxes are intermediate products. Line numbers refer to InternalSpelExpressionParser.

The inner parser's doParseExpression shows the whole order.

	@Override
	protected SpelExpression doParseExpression(String expressionString, @Nullable ParserContext context)
			throws ParseException {

		checkExpressionLength(expressionString);

		try {
			this.expressionString = expressionString;
			Tokenizer tokenizer = new Tokenizer(expressionString);
			this.tokenStream = tokenizer.process();
			this.tokenStreamLength = this.tokenStream.size();
			this.tokenStreamPointer = 0;
			this.constructedNodes.clear();
			SpelNodeImpl ast = eatExpression();
			if (ast == null) {
				throw new SpelParseException(this.expressionString, 0, SpelMessage.OOD);
			}
			Token t = peekToken();
			if (t != null) {
				throw new SpelParseException(this.expressionString, t.startPos, SpelMessage.MORE_INPUT, toString(nextToken()));
			}
			return new SpelExpression(expressionString, ast, this.configuration);
		}
		catch (InternalParseException ex) {
			throw ex.getCause();
		}
	}

spring-expression/src/main/java/org/springframework/expression/spel/standard/InternalSpelExpressionParser.java, lines 131–157

Three steps. Tokenizer.process() builds the token list, eatExpression() eats those tokens and builds the tree, and if tokens are left over after eating, that is a MORE_INPUT error. The result is a SpelExpression bundling the string, the tree and the configuration.

What the tokenizer does

Tokenizer.process() looks at one character at a time and sorts it with a switch. It is long but the shape is simple.

	public List<Token> process() {
		while (this.pos < this.max) {
			char ch = this.charsToProcess[this.pos];
			if (isAlphabetic(ch)) {
				lexIdentifier();
			}
			else {
				switch (ch) {
					// ...
					case '#':
						pushCharToken(TokenKind.HASH);
						break;
					// ...
					case '@':
						pushCharToken(TokenKind.BEAN_REF);
						break;
					// ...
					case '$':
						if (isTwoCharToken(TokenKind.SELECT_LAST)) {
							pushPairToken(TokenKind.SELECT_LAST);
						}
						else {
							lexIdentifier();  // '$' is another way to start an identifier
						}
						break;
					// ...
				}
			}
		}
		return this.tokens;
	}

spring-expression/src/main/java/org/springframework/expression/spel/standard/Tokenizer.java, lines 88–274

A letter starts an identifier (lexIdentifier, lines 448–467), a digit starts a numeric literal (lines 236–247), a quote starts a string literal (lines 255–260), and the other symbols are one- or two-character tokens. On a +, for example, it peeks at the next character to decide between ++ (INC) and + (PLUS) (lines 96–103). Every kind of token is in the TokenKind enum (lines 25–123). A token is a small object holding its kind, its text and its start and end positions (Token lines 28–37).

Applying these rules to T(java.lang.Math).max(a, 3) + 1 gives the following. Tokenizer is package-private, so this table was worked out by hand from the code above rather than printed by a program.

Characters Token Why
T IDENTIFIER Starts with a letter. That T is special is known to the parser, not the tokenizer
( ) , LPAREN RPAREN COMMA One-character symbols
java . lang . Math IDENTIFIER DOT IDENTIFIER DOT IDENTIFIER . is not an identifier character (lines 538–540)
max a IDENTIFIER
3 1 LITERAL_INT
+ PLUS The next character is not +
spaces (none) Skipped (lines 248–254)

Easy to get wrong

2. From tokens to a tree — recursive descent and precedence

Almost every method in the parser is called eatXxx, and each one calls the next. That call order is the operator precedence.

The call chain from eatExpression down through eatLogicalOrExpression, eatLogicalAndExpression, eatRelationalExpression, eatSumExpression, eatProductExpression, eatPowerIncDecExpression, eatUnaryExpression, eatPrimaryExpression and eatStartNode. The deeper the method, the tighter it binds; inside parentheses the chain starts again from the top. Each method is labelled with the operators it handles. The lower it is, the tighter it binds.

The two methods for + and * show the rule.

	//sumExpression: productExpression ( (PLUS^ | MINUS^) productExpression)*;
	@SuppressWarnings("NullAway") // Not null assertion performed in SpelNodeImpl constructor
	private @Nullable SpelNodeImpl eatSumExpression() {
		SpelNodeImpl expr = eatProductExpression();
		while (peekToken(TokenKind.PLUS, TokenKind.MINUS, TokenKind.INC)) {
			Token t = takeToken();  //consume PLUS or MINUS or INC
			SpelNodeImpl rhExpr = eatProductExpression();
			checkRightOperand(t, rhExpr);
			if (t.kind == TokenKind.PLUS) {
				expr = new OpPlus(t.startPos, t.endPos, expr, rhExpr);
			}
			else if (t.kind == TokenKind.MINUS) {
				expr = new OpMinus(t.startPos, t.endPos, expr, rhExpr);
			}
		}
		return expr;
	}

	// productExpression: powerExpr ((STAR^ | DIV^| MOD^) powerExpr)* ;
	private @Nullable SpelNodeImpl eatProductExpression() {
		SpelNodeImpl expr = eatPowerIncDecExpression();
		while (peekToken(TokenKind.STAR, TokenKind.DIV, TokenKind.MOD)) {
			Token t = takeToken();  // consume STAR/DIV/MOD
			SpelNodeImpl rhExpr = eatPowerIncDecExpression();
			checkOperands(t, expr, rhExpr);
			if (t.kind == TokenKind.STAR) {
				expr = new OpMultiply(t.startPos, t.endPos, expr, rhExpr);
			}
			// ...
		}
		return expr;
	}

spring-expression/src/main/java/org/springframework/expression/spel/standard/InternalSpelExpressionParser.java, lines 284–320

Feed it 1 + 2 * 3. eatSumExpression first calls eatProductExpression to get its left operand. That call eats only 1 and returns (the next token is +, not *, so the while is not entered). + is eaten, and eatProductExpression is called again; this time it takes 2, sees *, eats 3 as well, and returns OpMultiply(2, 3). So the result is OpPlus(1, OpMultiply(2, 3)). The deeper method eats as far as it can first, so it binds first. Parentheses are eatStartNode seeing ( and calling eatExpression() again (lines 913–930), so inside parentheses the chain starts from the top.

The comment above each method (sumExpression: productExpression ( (PLUS^ | MINUS^) productExpression)*;) is the grammar rule. This parser is those rules transcribed into methods by hand, which is why the Javadoc says "Handwritten SpEL parser" (line 85).

Method Handles Where
eatExpression =, ?: (Elvis), ? : (ternary) lines 173–214
eatLogicalOrExpression or, || lines 217–226
eatLogicalAndExpression and, && lines 229–238
eatRelationalExpression > < >= <= == != instanceof matches between lines 241–282
eatSumExpression + - code above
eatProductExpression * / % code above
eatPowerIncDecExpression ^, postfix ++ -- lines 324–340
eatUnaryExpression prefix ! + - ++ -- lines 344–372
eatPrimaryExpression a start node plus the dot-separated nodes after it → CompoundExpression lines 393–410
eatStartNode literals, parentheses, T(), null, new, names, #variables, @beans, selection/projection/indexer, {…} lines 543–566

Is a name a property or a method?

Both eatStartNode and eatDottedNode, which eats the node after a dot (lines 436–450), call this method.

	private boolean maybeEatMethodOrProperty(boolean nullSafeNavigation) {
		if (peekToken(TokenKind.IDENTIFIER)) {
			Token methodOrPropertyName = takeToken();
			SpelNodeImpl[] args = maybeEatMethodArgs();
			if (args == null) {
				// property
				push(new PropertyOrFieldReference(nullSafeNavigation, methodOrPropertyName.stringValue(),
						methodOrPropertyName.startPos, methodOrPropertyName.endPos));
				return true;
			}
			// method reference
			push(new MethodReference(nullSafeNavigation, methodOrPropertyName.stringValue(),
					methodOrPropertyName.startPos, methodOrPropertyName.endPos, args));
			return true;
		}
		return false;
	}

spring-expression/src/main/java/org/springframework/expression/spel/standard/InternalSpelExpressionParser.java, lines 795–811

An identifier followed by ( is a MethodReference; otherwise it is a PropertyOrFieldReference. The parser does not check whether the name exists. That is evaluation's job. T(…) is similar: maybeEatTypeReference builds a TypeReference only when the identifier is exactly T and a ( follows, and eatPossiblyQualifiedId gathers the IDENTIFIER and DOT tokens inside into one QualifiedIdentifier (lines 601–628, lines 759–778).

Example: printing the tree

Every node is a subclass of SpelNodeImpl (line 50) and has a children array (line 59). The spel.ast package has 53 files (including package-info.java). I took the root node from SpelExpression.getAST() and printed it recursively, children and all. This example was run on the release version, Spring Framework 7.0.9 (not on a self-built 7.1.0-SNAPSHOT, which is what this series reads).

// An example written for this post (not Spring's code)
SpelExpressionParser parser = new SpelExpressionParser();
Scores root = new Scores(7, null);                                 // getA() = 7, getNickname() = null
StandardEvaluationContext ctx = new StandardEvaluationContext(root);  // #root = root

for (String text : List.of("T(java.lang.Math).max(a, 3) + 1", "1 + 2 * 3", "(1 + 2) * 3",
        "a > 5 and a < 10", "'a=' + a", "#root.a == a",
        "nickname ?: '(별명 없음)'", "nickname?.length()", "{1, 2, 3}.?[#this > 1]")) {
    System.out.printf("%-34s → %s%n", text, parser.parseExpression(text).getValue(ctx));
}

System.out.println();
for (String text : List.of("T(java.lang.Math).max(a, 3) + 1", "1 + 2 * 3")) {
    SpelExpression expr = parser.parseRaw(text);
    System.out.println("AST of \"" + text + "\"  (toStringAST = " + expr.toStringAST() + ")");
    printTree(expr.getAST(), 1);      // prints each node's class name and toStringAST(), recursively
}

The examples print their headings and labels in Korean. In the output blocks of this English version those labels are translated; the values, the order, and anything printed by Spring are the actual output. Korean that is part of an expression stays as it is.

The output (the Korean string '(별명 없음)' means "(no nickname)").

T(java.lang.Math).max(a, 3) + 1    → 8
1 + 2 * 3                          → 7
(1 + 2) * 3                        → 9
a > 5 and a < 10                   → true
'a=' + a                           → a=7
#root.a == a                       → true
nickname ?: '(별명 없음)'              → (별명 없음)
nickname?.length()                 → null
{1, 2, 3}.?[#this > 1]             → [2, 3]

AST of "T(java.lang.Math).max(a, 3) + 1"  (toStringAST = (T(java.lang.Math).max(a,3) + 1))
  OpPlus  (T(java.lang.Math).max(a,3) + 1)
    CompoundExpression  T(java.lang.Math).max(a,3)
      TypeReference  T(java.lang.Math)
        QualifiedIdentifier  java.lang.Math
          Identifier  java
          Identifier  lang
          Identifier  Math
      MethodReference  max(a,3)
        PropertyOrFieldReference  a
        IntLiteral  3
    IntLiteral  1
AST of "1 + 2 * 3"  (toStringAST = (1 + (2 * 3)))
  OpPlus  (1 + (2 * 3))
    IntLiteral  1
    OpMultiply  (2 * 3)
      IntLiteral  2
      IntLiteral  3

The tree of our headline expression has OpPlus at the root; its left child is a CompoundExpression joining T(java.lang.Math) and max(a,3) with a dot, and its right child is IntLiteral 1. toStringAST() adds parentheses, so you can see the grouping with your own eyes.

Easy to get wrong

3. From a tree to a value — getValue, ExpressionState, EvaluationContext

Here is SpelExpression.getValue(EvaluationContext). The compilation branch at the top is left for section 5; only the last three lines matter now.

	@Override
	public @Nullable Object getValue(EvaluationContext context) throws EvaluationException {
		Assert.notNull(context, "EvaluationContext must not be null");

		CompiledExpression compiledAst = this.compiledAst;
		if (compiledAst != null && context.isCompilationSupported()) {
			// ...
		}

		ExpressionState expressionState = new ExpressionState(context, this.configuration);
		Object result = this.ast.getValue(expressionState);
		checkCompile(expressionState);
		return result;
	}

spring-expression/src/main/java/org/springframework/expression/spel/standard/SpelExpression.java, lines 254–280

Two objects appear here. The EvaluationContext is shared across many evaluations; the ExpressionState lives for this one evaluation only. The Javadoc of ExpressionState spells out the difference: changes to it are not seen by other expressions, whereas changes to the EvaluationContext are (lines 40–45). What ExpressionState holds is the context, the root object, the configuration, and a stack of "the object currently before the dot" (contextObjects) (lines 55–74).

this.ast.getValue(state) calls the root node's getValueInternal, and each node calls its children's getValueInternal (SpelNodeImpl lines 113–115, line 205). Values travel as TypedValue, an object plus its type information (TypedValue lines 34–44).

SpelExpression.getValue creates an ExpressionState and calls getValueInternal recursively from the root node. CompoundExpression passes each piece's value on as the next piece's target; TypeReference asks the TypeLocator, MethodReference the MethodResolvers, PropertyOrFieldReference the PropertyAccessors. OpPlus adds to give TypedValue 8, and if a result type was asked for the TypeConverter converts it through the ConversionService. All four come from the shared EvaluationContext. The yellow box is the shared context; blue boxes are the steps of this one evaluation.

What a node asks the context

EvaluationContext is an interface that hands back, by kind, whatever a node needs to ask about.

Method Returns Used by
getRootObject() the root object a bare name like a, and #root (line 73)
getPropertyAccessors() accessors that read and write properties PropertyOrFieldReference (lines 79–81)
getMethodResolvers() resolvers that find a method MethodReference (lines 105–107)
getConstructorResolvers() resolvers that find a constructor ConstructorReference (new) (lines 97–99)
getTypeLocator() something that finds a class by name TypeReference (T()) (line 118)
getBeanResolver() something that finds a bean by name; may be null BeanReference (@bean) (line 112)
getTypeConverter() something that converts a value's type fitting the result type, fitting method arguments (line 123)
lookupVariable(name) the #name variable VariableReference (line 172)

Following our headline expression's tree from the top:

  1. CompoundExpression evaluates its pieces in turn, pushing each piece's value with pushActiveContextObject so that it becomes the next piece's target (lines 52–86). The Class object that T(java.lang.Math) evaluates to becomes the target of max(a,3).
  2. TypeReference takes the string "java.lang.Math" from its QualifiedIdentifier child and calls state.findType() (lines 56–77), which is the context's TypeLocator.findType() (ExpressionState lines 179–181). The default implementation, StandardTypeLocator, registers java.lang as an import prefix in its constructor, so T(Math) works as a shorthand (lines 76–80, lines 117–130).
  3. MethodReference first evaluates the arguments a and 3. While evaluating arguments it temporarily resets the target to the root (lines 254–260). That is why the a in max(a, 3) is a property of the root object, not of Math. It then walks the context's MethodResolver list looking for a match (line 298) and throws METHOD_NOT_FOUND if there is none (lines 220–231). The default resolver, ReflectiveMethodResolver, gathers the methods with that name and matches argument types; if they do not match exactly, it also asks the context's TypeConverter whether they could be converted (lines 116–123, line 179).
  4. PropertyOrFieldReference a walks the PropertyAccessor list asking canRead, and calls read on the first one that says yes.
		List<PropertyAccessor> accessorsToTry =
				AccessorUtils.getAccessorsToTry(target, evalContext.getPropertyAccessors());
		// Go through the accessors that may be able to resolve it. If they are a cacheable accessor then
		// get the accessor and use it. If they are not cacheable but report they can read the property
		// then ask them to read it.
		try {
			for (PropertyAccessor accessor : accessorsToTry) {
				// First, attempt to find the property on the target object.
				if (accessor.canRead(evalContext, target, name)) {
					if (accessor instanceof ReflectivePropertyAccessor reflectivePropertyAccessor) {
						accessor = reflectivePropertyAccessor.createOptimalAccessor(
								evalContext, target, name);
					}
					this.cachedReadAccessor = accessor;
					state.trackOperation();
					return accessor.read(evalContext, target, name);
				}
				// ...
			}
		}
		// ...

spring-expression/src/main/java/org/springframework/expression/spel/ast/PropertyOrFieldReference.java, lines 243–280

AccessorUtils.getAccessorsToTry puts accessors that specifically claim the target type first and accessors that accept any type last (comment, lines 59–66). The default accessor, ReflectivePropertyAccessor, reads a getter (getA(), isActive(), or the record-style a()) or a public field through reflection (Javadoc, lines 56–63). Once found, the accessor is stored in the node's cachedReadAccessor and used directly from the next evaluation on (line 83, lines 228–241). If nothing works, the result is PROPERTY_OR_FIELD_NOT_READABLE, that is "Property or field 'x' cannot be found on object of type 'y' - maybe not public or not valid?" (lines 289–295, SpelMessage lines 63–64).

  1. OpPlus takes both children's values and, if both are numbers, adds them (the result follows the wider type, in the order BigDecimal > double > float > BigInteger > long > int); if either is a string it concatenates; anything else goes to the context's OperatorOverloader (lines 70–156). The branch that turned the example's 'a=' + a into a=7 is lines 141–146. An IntLiteral just returns the value fixed when it was built (Literal lines 50–53, IntLiteral lines 35–45).

Where Part 14 connects — TypeConverter

If you ask for a result type, as in getValue(ctx, Integer.class), ExpressionUtils.convertTypedValue calls the context's TypeConverter at the very end (lines 49–64). SpEL's TypeConverter is a small interface with just canConvert and convertValue (TypeConverter lines 42, 58), and the default implementation, StandardTypeConverter, simply hands over to the ConversionService from Part 14. If none is given it uses DefaultConversionService.getSharedInstance() (lines 50–52); convertValue calls conversionService.convert(...) and wraps a ConversionException as TYPE_CONVERSION_ERROR (lines 79–89). In other words SpEL has no conversion rules of its own; it uses Part 14's 52 converters as they are.

Easy to get wrong

4. StandardEvaluationContext and SimpleEvaluationContext — why two

The Javadoc of EvaluationContext says there are two default implementations (lines 29–39). The difference is in what each one hands back.

StandardEvaluationContext fills in a reflection-based default for everything it is asked about. The first time it is asked for accessors it creates a ReflectivePropertyAccessor; for constructor resolvers a ReflectiveConstructorResolver; for method resolvers a ReflectiveMethodResolver (lines 568–606). TypeLocator and TypeConverter likewise become a StandardTypeLocator and a StandardTypeConverter on first use (lines 379–384, lines 398–403). Only the BeanResolver has no default and must be set with setBeanResolver (line 112, lines 343–353). The Javadoc says this context "exposes the complete SpEL language, backed by reflection" and must never be used to evaluate an expression from an untrusted source (lines 67–73): it can reach any class, T(java.lang.Runtime) included.

SimpleEvaluationContext (since 4.3.15, line 119) is the opposite. You build it with a builder, putting in only what you choose, and whatever you did not choose is empty or blocked. This is where type references get blocked.

	private static final TypeLocator typeNotFoundTypeLocator = typeName -> {
		throw new SpelEvaluationException(SpelMessage.TYPE_NOT_FOUND, typeName);
	};
	// ...
	/**
	 * {@code SimpleEvaluationContext} does not support use of type references.
	 * @return {@code TypeLocator} implementation that raises a
	 * {@link SpelEvaluationException} with {@link SpelMessage#TYPE_NOT_FOUND}
	 */
	@Override
	public TypeLocator getTypeLocator() {
		return typeNotFoundTypeLocator;
	}

spring-expression/src/main/java/org/springframework/expression/spel/support/SimpleEvaluationContext.java, lines 130–132 and 215–223

T(…) parses fine; what happens is that when TypeReference asks the TypeLocator at evaluation time, the locator that answers always throws TYPE_NOT_FOUND. Everything else works the same way.

Asked for StandardEvaluationContext SimpleEvaluationContext
PropertyAccessor ReflectivePropertyAccessor only what you gave the builder. forReadOnlyDataBinding() gives one read-only DataBindingPropertyAccessor (lines 356–358)
MethodResolver ReflectiveMethodResolver none by default. withInstanceMethods() adds a DataBindingMethodResolver (lines 464–467)
ConstructorResolver ReflectiveConstructorResolver none (the interface's default empty list)
TypeLocator StandardTypeLocator one that always throws TYPE_NOT_FOUND
BeanResolver null (used if you set one) always null (lines 211–213)
TypeConverter StandardTypeConverter StandardTypeConverter (replaceable) (line 163)
assignment = ++ -- allowed blocked by forReadOnlyDataBinding() (lines 304–306, Assign lines 44–45)
compilation (section 5) allowed not by default (lines 317–319)

The five things a node of the expression asks the context during evaluation, and how the two contexts answer. StandardEvaluationContext gives ReflectiveMethodResolver as the method resolver, so name.length() is 3; StandardTypeLocator as the TypeLocator, so T(java.lang.Math).max(1, 2) is 2; ReflectiveConstructorResolver as the constructor resolver, so new java.util.ArrayList() is an ArrayList; and it allows assignment, so name = 'Lee' really changes the object. Only its BeanResolver is null, so @someBean gives NO_BEAN_RESOLVER_REGISTERED. SimpleEvaluationContext.forReadOnlyDataBinding() has no method resolver, so name.length() gives METHOD_NOT_FOUND (3 once you call withInstanceMethods()); its TypeLocator always throws, so T(…) gives TYPE_NOT_FOUND; its constructor resolvers are an empty list, so new gives CONSTRUCTOR_NOT_FOUND; it blocks assignment, giving NOT_ASSIGNABLE; and its BeanResolver is always null, giving NO_BEAN_RESOLVER_REGISTERED The two contexts answer the same questions differently. Green boxes are expressions that were evaluated, red boxes are ones that were blocked; the results come from the real output of the example below.

DataBindingPropertyAccessor is a subclass of ReflectivePropertyAccessor that excludes methods declared on Object and Class, and ClassLoader targets, so "technical" properties like getClass() are out of reach (line 50, lines 61–65). DataBindingMethodResolver has the same restriction and also drops static methods (lines 59–65). The builder throws IllegalArgumentException if you try to pass in a raw ReflectivePropertyAccessor or ReflectiveMethodResolver (lines 336–344, lines 445–454).

So the reason there are two is purpose. Where Spring itself evaluates expressions, such as container configuration, it needs T(), @bean and new, so it uses Standard. Where the data comes from users, such as form values or filter conditions, it uses Simple, which opens little more than property reads. The Javadoc of Simple says its target is "data binding scenarios" (lines 44–46).

Example: the same expressions in three contexts

With a Person (getter and setter for name) as the root, six expressions were run against a StandardEvaluationContext, a SimpleEvaluationContext.forReadOnlyDataBinding(), and the latter with withInstanceMethods() added. On failure, the constant name from SpelEvaluationException.getMessageCode() is printed. Run on Spring Framework 7.0.9.

// An example written for this post (not Spring's code)
EvaluationContext standard = new StandardEvaluationContext();
EvaluationContext readOnly = SimpleEvaluationContext.forReadOnlyDataBinding().build();
EvaluationContext withMethods = SimpleEvaluationContext.forReadOnlyDataBinding().withInstanceMethods().build();

System.out.printf("%-28s | %-22s | %-30s | %s%n", "expression", "Standard", "Simple(readOnly)", "Simple + withInstanceMethods");
for (String text : List.of("name", "name.length()", "T(java.lang.Math).max(1, 2)",
        "new java.util.ArrayList()", "@someBean", "name = 'Lee'")) {
    // the source warns against using one Expression with two kinds of context, so parse once per context
    System.out.printf("%-28s | %-22s | %-30s | %s%n", text,
            eval(parser.parseExpression(text), standard, kim),
            eval(parser.parseExpression(text), readOnly, kim),
            eval(parser.parseExpression(text), withMethods, kim));
}
System.out.println("name is now " + kim.getName());

The output (labels translated, as above).

expression                   | Standard               | Simple(readOnly)               | Simple + withInstanceMethods
name                         | Kim                    | Kim                            | Kim
name.length()                | 3                      | METHOD_NOT_FOUND               | 3
T(java.lang.Math).max(1, 2)  | 2                      | TYPE_NOT_FOUND                 | TYPE_NOT_FOUND
new java.util.ArrayList()    | ArrayList              | CONSTRUCTOR_NOT_FOUND          | CONSTRUCTOR_NOT_FOUND
@someBean                    | NO_BEAN_RESOLVER_REGISTERED | NO_BEAN_RESOLVER_REGISTERED    | NO_BEAN_RESOLVER_REGISTERED
name = 'Lee'                 | Lee                    | NOT_ASSIGNABLE                 | NOT_ASSIGNABLE
name is now Lee

It matches the table. name.length() is METHOD_NOT_FOUND without a method resolver and comes back to life with withInstanceMethods(); T() and new stay blocked; assignment is NOT_ASSIGNABLE. The last line means name = 'Lee' under Standard really changed the object.

Easy to get wrong

5. Compiler mode — from a tree to bytecode

Section 3's approach walks the tree and calls reflection on every evaluation. For places that evaluate the same expression tens of thousands of times (the Javadoc names Spring Integration), there has been a compiler since 4.1 (SpelCompilerMode lines 23–38). There are three modes.

Mode Behaviour Where
OFF never compile. The default lines 46–51
IMMEDIATE compile as soon as possible (the Javadoc says "typically after the first interpreted evaluation"; the code, as shown below, does it after the second). If the compiled expression fails, the caller gets the exception lines 53–61
MIXED compile after a number of interpreted runs. On failure, silently fall back to interpreting and try again later lines 63–78

The mode goes into SpelParserConfiguration; if you do not set one, the system property spring.expression.compiler.mode is consulted, and failing that it is OFF (line 114, lines 147–153, line 566). In this commit (7.1) the constructors are @Deprecated in favour of SpelParserConfiguration.builder() (lines 30–36, lines 210–213).

When does it compile — checkCompile

The last line of section 3's getValue was checkCompile.

	private void checkCompile(ExpressionState expressionState) {
		this.interpretedCount.incrementAndGet();
		if (!expressionState.getEvaluationContext().isCompilationSupported()) {
			return;
		}
		SpelCompilerMode compilerMode = expressionState.getConfiguration().getCompilerMode();
		if (compilerMode != SpelCompilerMode.OFF) {
			if (compilerMode == SpelCompilerMode.IMMEDIATE) {
				if (this.interpretedCount.get() > 1) {
					compileExpression();
				}
			}
			else {
				// compilerMode = SpelCompilerMode.MIXED
				if (this.interpretedCount.get() > INTERPRETED_COUNT_THRESHOLD) {
					compileExpression();
				}
			}
		}
	}

spring-expression/src/main/java/org/springframework/expression/spel/standard/SpelExpression.java, lines 479–498

It counts interpreted runs: IMMEDIATE compiles after the 2nd, MIXED after more than INTERPRETED_COUNT_THRESHOLD = 100 (line 66). The isCompilationSupported() check before that is @since 7.0.9, and it is where section 4's SimpleEvaluationContext answers false (EvaluationContext lines 188–208).

If a compiledAst exists and the context supports compilation, getValue goes straight to bytecode; on an exception MIXED falls back to interpreting and IMMEDIATE throws. Otherwise it interprets, and checkCompile does nothing for OFF, compiles from the 2nd run for IMMEDIATE and from the 101st for MIXED, calling compileExpression, where SpelCompiler checks isCompilable, runs generateCode, defines the class through a ChildClassLoader and stores it in compiledAst. After 100 failures it gives up. The yellow box is where the compiled form is kept; the red box is the exception that reaches the caller in IMMEDIATE mode. Line numbers refer to SpelExpression.

What gets generated — SpelCompiler

compileExpression() obtains the SpelCompiler for its class loader (there is one per class loader) and calls compile(ast) (lines 506–552, SpelCompiler lines 248–256). The compiler first asks the root node isCompilable(), and if so builds a class with createExpressionClass (lines 101–122).

	private @Nullable Class<? extends CompiledExpression> createExpressionClass(SpelNodeImpl expressionToCompile) {
		// Create class outline:
		// org.springframework.expression.spel.generated.CompiledExpression##### extends org.springframework.expression.spel.CompiledExpression
		String className = "org/springframework/expression/spel/generated/CompiledExpression" + getNextSuffix();
		String evaluationContextClass = "org/springframework/expression/EvaluationContext";
		ClassWriter cw = new ExpressionClassWriter();
		cw.visit(V17, ACC_PUBLIC, className, null, "org/springframework/expression/spel/CompiledExpression", null);
		// ...
		// Create getValue() method
		mv = cw.visitMethod(ACC_PUBLIC, "getValue",
				"(Ljava/lang/Object;L" + evaluationContextClass + ";)Ljava/lang/Object;", null,
				new String[] {"org/springframework/expression/EvaluationException"});
		mv.visitCode();

		CodeFlow cf = new CodeFlow(className, cw);

		// Ask the expression AST to generate the body of the method
		try {
			expressionToCompile.generateCode(mv, cf);
		}
		catch (IllegalStateException ex) {
			// ...
			return null;
		}
		// ...
		byte[] data = cw.toByteArray();
		// ...
		return loadClass(StringUtils.replace(className, "/", "."), data);
	}

spring-expression/src/main/java/org/springframework/expression/spel/standard/SpelCompiler.java, lines 135–189

Using ASM's ClassWriter it creates one class extending CompiledExpression (which has a single abstract method, getValue(target, context), lines 32–39), and the root node's generateCode fills in the body of that getValue. Nodes call their children's generateCode, so the tree is walked just as before, but this time instead of producing a value, each node writes the bytecode that produces the value. OpPlus, for example, emits StringBuilder.append for strings and instructions like IADD for numbers (lines 241–275). The resulting byte array is defined through a dedicated ChildClassLoader, and a fresh loader is started every 100 classes so that piled-up classes do not pin memory (line 71, lines 221–237).

Why it must interpret first — exitTypeDescriptor

SpelNode.isCompilable() defaults to false, and each node overrides it with, roughly, "the result type is known and the children are compilable" (lines 125–127). That "result type" is SpelNodeImpl.exitTypeDescriptor, and as its Javadoc says, a literal knows it immediately but a property access or method call only knows after one evaluation (lines 63–72). From the string name alone you cannot tell whether it is a String or an int. PropertyOrFieldReference.isCompilable is true only when the accessor cached in section 3 is a CompilablePropertyAccessor that says it is compilable (lines 369–372), and the optimal accessor created by ReflectivePropertyAccessor says so only when both the member and its declaring class are public (lines 698–701). MethodReference additionally requires that the target is not a proxy, that no argument conversion happened, and that the method is public (lines 360–378). Compiled code contains no checks, so if an object of a different type from the one seen during interpretation comes along it fails, much like a ClassCastException. The SpelCompiler Javadoc gives that as the reason it is not on by default (lines 46–61).

Example: watching the moment of compilation

compiledAst is a private field, so it is read through reflection. Run on Spring Framework 7.0.9, which does not yet have builder(), hence the constructor.

// An example written for this post (not Spring's code)
SpelParserConfiguration config = new SpelParserConfiguration(
        SpelCompilerMode.IMMEDIATE, CompileExample.class.getClassLoader());
SpelExpression expr = new SpelExpressionParser(config).parseRaw("name.length() * 2");
Person kim = new Person("Kim");                              // public static class, public getName()
for (int i = 1; i <= 3; i++) {
    System.out.println("① IMMEDIATE, getValue #" + i + " = " + expr.getValue(kim)
            + ", compiledAst = " + compiledClassName(expr));
}

SpelExpression plain = new SpelExpressionParser().parseRaw("name.length() * 2");   // OFF
plain.getValue(kim); plain.getValue(kim);
System.out.println("② OFF, after 2 interpreted runs compiledAst = " + compiledClassName(plain));
System.out.println("   compileExpression() = " + plain.compileExpression() + ", compiledAst = " + compiledClassName(plain));

SpelExpression fresh = new SpelExpressionParser().parseRaw("name.length() * 2");
System.out.println("③ compileExpression() before any run = " + fresh.compileExpression());

SpelExpression simple = new SpelExpressionParser(config).parseRaw("name.length() * 2");
SimpleEvaluationContext readOnly = SimpleEvaluationContext.forReadOnlyDataBinding().withInstanceMethods().build();
for (int i = 1; i <= 3; i++) { simple.getValue(readOnly, kim); }
System.out.println("④ IMMEDIATE + SimpleEvaluationContext, after 3 runs compiledAst = " + compiledClassName(simple));

The output (labels translated, as above).

① IMMEDIATE, getValue #1 = 6, compiledAst = null
① IMMEDIATE, getValue #2 = 6, compiledAst = org.springframework.expression.spel.generated.CompiledExpression00001
① IMMEDIATE, getValue #3 = 6, compiledAst = org.springframework.expression.spel.generated.CompiledExpression00001
② OFF, after 2 interpreted runs compiledAst = null
   compileExpression() = true, compiledAst = org.springframework.expression.spel.generated.CompiledExpression00002
③ compileExpression() before any run = false
④ IMMEDIATE + SimpleEvaluationContext, after 3 runs compiledAst = null

In ①, exactly as checkCompile's > 1 says, CompiledExpression00001 appeared after the second interpreted run. The five-digit number in the class name is getNextSuffix() from the code above (lines 124–126). ② shows that even in OFF mode you can call compileExpression() yourself; ③ that an expression never interpreted has no exitTypeDescriptor and cannot be compiled; ④ that under a SimpleEvaluationContext nothing is compiled even in IMMEDIATE mode.

Easy to get wrong

6. Connecting to the container — the path a @Value("#{…}") takes

So far it has all been spring-expression on its own. Now for where the container picks it up.

prepareBeanFactory in refresh plugs in a StandardBeanExpressionResolver and registers environment, systemProperties and systemEnvironment as singletons. A @Value string goes through doResolveDependency, where resolveEmbeddedValue resolves ${} first, then evaluateBeanDefinitionString creates a BeanExpressionContext and hands over to StandardBeanExpressionResolver.evaluate. There the string is parsed as a #{ } template and cached, a StandardEvaluationContext with the BeanExpressionContext as root, four accessors, a BeanFactoryResolver, a StandardTypeLocator and a StandardTypeConverter is cached, getValue runs, and the result is converted by Part 14's TypeConverter. Left: when the container starts. Right: when one @Value is injected. Yellow boxes are caches.

① At start-up: plug in the resolver and register three names

This is the second line of prepareBeanFactory, the third step of refresh() from Part 7.

	protected void prepareBeanFactory(ConfigurableListableBeanFactory beanFactory) {
		// Tell the internal bean factory to use the context's class loader etc.
		beanFactory.setBeanClassLoader(getClassLoader());
		beanFactory.setBeanExpressionResolver(new StandardBeanExpressionResolver(beanFactory.getBeanClassLoader()));
		// ...
		// Register default environment beans.
		if (!beanFactory.containsLocalBean(ENVIRONMENT_BEAN_NAME)) {
			beanFactory.registerSingleton(ENVIRONMENT_BEAN_NAME, getEnvironment());
		}
		if (!beanFactory.containsLocalBean(SYSTEM_PROPERTIES_BEAN_NAME)) {
			beanFactory.registerSingleton(SYSTEM_PROPERTIES_BEAN_NAME, getEnvironment().getSystemProperties());
		}
		if (!beanFactory.containsLocalBean(SYSTEM_ENVIRONMENT_BEAN_NAME)) {
			beanFactory.registerSingleton(SYSTEM_ENVIRONMENT_BEAN_NAME, getEnvironment().getSystemEnvironment());
		}
		// ...
	}

spring-context/src/main/java/org/springframework/context/support/AbstractApplicationContext.java, lines 730–776

The systemProperties in #{systemProperties['os.name']} is not a magic word; it is the name of a singleton bean registered right here. The constants are "environment", "systemProperties" and "systemEnvironment" (ConfigurableApplicationContext lines 90, 96, 102). The Javadoc of StandardBeanExpressionResolver says the same: "All beans in the containing BeanFactory are made available as predefined variables with their common bean name" (lines 49–51). A bare BeanFactory has no resolver (the beanExpressionResolver field is null, AbstractBeanFactory line 133), so #{…} stays a literal string there. It is the ApplicationContext that plugs one in.

② Injecting one @Value: ${} first, #{} second

This is step 2 of doResolveDependency, which we saw in Part 14.

			// Step 2: pre-defined value or expression, for example, from @Value
			Object value = getAutowireCandidateResolver().getSuggestedValue(descriptor);
			if (value != null) {
				if (value instanceof String strValue) {
					String resolvedValue = resolveEmbeddedValue(strValue);
					BeanDefinition bd = (beanName != null && containsBean(beanName) ?
							getMergedBeanDefinition(beanName) : null);
					value = evaluateBeanDefinitionString(resolvedValue, bd);
				}
				TypeConverter converter = (typeConverter != null ? typeConverter : getTypeConverter());
				try {
					return converter.convertIfNecessary(value, type, descriptor.getTypeDescriptor());
				}

spring-beans/src/main/java/org/springframework/beans/factory/support/DefaultListableBeanFactory.java, lines 1675–1687

Three steps, in order. resolveEmbeddedValue runs the registered StringValueResolvers in turn to resolve ${…} (AbstractBeanFactory lines 966–978). If nobody registered one, finishBeanFactoryInitialization adds a default that calls Environment.resolvePlaceholders (AbstractApplicationContext lines 961–966). Then evaluateBeanDefinitionString computes the #{…}, and finally Part 14's convertIfNecessary fits the result to the field type.

	protected @Nullable Object evaluateBeanDefinitionString(@Nullable String value, @Nullable BeanDefinition beanDefinition) {
		if (this.beanExpressionResolver == null) {
			return value;
		}

		Scope scope = null;
		if (beanDefinition != null) {
			String scopeName = beanDefinition.getScope();
			if (scopeName != null) {
				scope = getRegisteredScope(scopeName);
			}
		}
		return this.beanExpressionResolver.evaluate(value, new BeanExpressionContext(this, scope));
	}

spring-beans/src/main/java/org/springframework/beans/factory/support/AbstractBeanFactory.java, lines 1662–1675

BeanExpressionContext is a small object bundling the bean factory and the scope; its containsObject and getObject look in the bean factory first and then ask the scope's resolveContextualObject (lines 29–66). This becomes the expression's root object. Strings that arrive through XML or PropertyValues reach the same evaluateBeanDefinitionString via BeanDefinitionValueResolver (lines 311–313), and even a bean's class name can be an expression (the comment says "supported as of 4.2", lines 1617–1622).

③ StandardBeanExpressionResolver.evaluate — four accessors and two caches

	@Override
	public @Nullable Object evaluate(@Nullable String value, BeanExpressionContext beanExpressionContext) throws BeansException {
		if (!StringUtils.hasLength(value)) {
			return value;
		}
		try {
			Expression expr = this.expressionCache.computeIfAbsent(value, expression ->
					this.expressionParser.parseExpression(expression, this.beanExpressionParserContext));
			EvaluationContext evalContext = this.evaluationCache.computeIfAbsent(beanExpressionContext, bec -> {
					ConfigurableBeanFactory beanFactory = bec.getBeanFactory();
					StandardEvaluationContext sec = new StandardEvaluationContext(bec);
					sec.addPropertyAccessor(new BeanExpressionContextAccessor());
					sec.addPropertyAccessor(new BeanFactoryAccessor());
					sec.addPropertyAccessor(new org.springframework.expression.spel.support.MapAccessor());
					sec.addPropertyAccessor(new EnvironmentAccessor());
					sec.setBeanResolver(new BeanFactoryResolver(beanFactory));
					sec.setTypeLocator(new StandardTypeLocator(beanFactory.getBeanClassLoader()));
					sec.setTypeConverter(new StandardTypeConverter(() -> {
						ConversionService cs = beanFactory.getConversionService();
						return (cs != null ? cs : DefaultConversionService.getSharedInstance());
					}));
					customizeEvaluationContext(sec);
					return sec;
				});
			return expr.getValue(evalContext);
		}
		catch (Throwable ex) {
			throw new BeanExpressionException("Expression parsing failed", ex);
		}
	}

spring-context/src/main/java/org/springframework/context/expression/StandardBeanExpressionResolver.java, lines 160–189

This one method threads sections 1 to 4 together.

Two ways to reach a bean in the container's context. #{otherBean.count * 2} tries to read the property otherBean of the root, a BeanExpressionContext; BeanExpressionContextAccessor.canRead checks that the target is a BeanExpressionContext and containsObject('otherBean'); then BeanExpressionContext.getObject('otherBean') returns the object via getBean if the bean factory has it (otherwise via the scope's resolveContextualObject). ReflectivePropertyAccessor reads count on that OtherBean through getCount(), giving 21, and times 2 gives 42. For #{@otherBean.count}, a BeanReference asks the context for its BeanResolver, BeanFactoryResolver returns the object with beanFactory.getBean('otherBean'), and count is 21 otherBean (a name) and @otherBean (a bean reference) reach the same bean by different routes. Yellow boxes are the accessor and resolver the container plugged in; 42 and 21 are the real output of the example below.

Example: eight @Values

Run on Spring Framework 7.0.9. OtherBean is a bean whose getCount() returns 21.

// An example written for this post (not Spring's code)
public static class Holder {
    @Value("${os.name}") String placeholder;                          // ${…}: resolved by the Environment (not SpEL)
    @Value("#{systemProperties['os.name']}") String spel;             // #{…}: computed by SpEL
    @Value("#{environment['os.name']}") String viaEnvironment;        // the environment bean + EnvironmentAccessor
    @Value("#{'${os.name}'.toUpperCase()}") String both;              // ${…} is resolved first, then SpEL computes the result
    @Value("#{otherBean.count * 2}") int viaName;                     // a bean name as a property name
    @Value("#{@otherBean.count}") int viaBeanRef;                     // @name: goes through the BeanResolver
    @Value("#{T(java.lang.Math).max(otherBean.count, 100)}") int typeRef;   // the container's context allows T() too
    @Value("port #{8080 + 1}") String template;                       // a template mixed with text: the result is a string
}

The output.

${os.name}                       = Windows 11
#{systemProperties['os.name']}   = Windows 11
#{environment['os.name']}        = Windows 11
#{'${os.name}'.toUpperCase()}    = WINDOWS 11
#{otherBean.count * 2}           = 42
#{@otherBean.count}              = 21
#{T(java.lang.Math).max(...)}    = 100
port #{8080 + 1}                 = port 8081
BeanExpressionResolver = StandardBeanExpressionResolver

The fourth line shows the order from ②. ${os.name} became Windows 11 first, giving #{'Windows 11'.toUpperCase()}, and then SpEL computed it. otherBean.count * 2 and T(java.lang.Math).max(…) work exactly as the Standard column of section 4's table says, and port 8081 on the last line is the template's CompositeStringExpression. The 100 for typeRef came out as an Integer and went through Part 14's convertIfNecessary into the int field.

Easy to get wrong

If you want to go deeper

A lot was left out to keep the main thread visible. Here are names and locations only. How selection (?[], ^[], $[]) and projection (![]) open a scope per element (Selection lines 140–160). The indexer […], which accepts arrays, lists, maps, strings and objects, and IndexAccessor, added in 6.2 (Indexer lines 55–64). registerFunction for #function() (StandardEvaluationContext lines 493–507). autoGrowNullReferences, which creates null properties on the fly, and auto-growing collections (SpelParserConfiguration line 56, lines 160–164). OperatorOverloader, which opens up operators between user types, and TypeComparator, which handles comparison (EvaluationContext lines 128–134). Safety limits such as the cap on the size of a power result (line 79). CodeFlow, the helper for bytecode generation (line 47), and CompilablePropertyAccessor (lines 31–51). And MethodBasedEvaluationContext (line 45), which lets @Cacheable(key = "#id") and @EventListener(condition = "…") refer to method arguments as #p0 or #id, together with CachedExpressionEvaluator (line 37), which caches those expressions; both are covered in Part 27.

Recap

Concept In one line
SpelExpressionParser The thread-safe entry point. Creates a new, non-thread-safe InternalSpelExpressionParser per parse
Tokenizer Reads one character at a time into a Token list. $ is an identifier character; #{ is not a token
Recursive descent parser eatExpression → … → eatStartNode. The deeper the method, the tighter it binds = precedence
Tree (AST) The SpelNodeImpl subclasses in spel.ast. A name followed by ( is a MethodReference, otherwise a PropertyOrFieldReference
getValue Creates an ExpressionState (for this evaluation) and calls the root node's getValueInternal recursively
EvaluationContext What nodes ask: accessors, method resolvers, TypeLocator, BeanResolver, TypeConverter, variables
TypeConverter Part 14's ConversionService, wrapped as is (StandardTypeConverter)
StandardEvaluationContext Fills in every reflection-based default. Never for untrusted expressions
SimpleEvaluationContext Opens only what you chose. T() is always TYPE_NOT_FOUND, no @bean or new, and read-only also blocks assignment
Compiler mode OFF (default), IMMEDIATE (from the 2nd run), MIXED (from the 101st). Generates a CompiledExpression subclass with ASM, using types learned while interpreting
The path of #{…} resolveEmbeddedValue (${}) → evaluateBeanDefinitionString → StandardBeanExpressionResolver.evaluate → convertIfNecessary
The container's context Root = BeanExpressionContext, four accessors that turn bean names into properties, BeanFactoryResolver, the bean class loader, the container's ConversionService

What to look at next

With this post, three of the four units of Phase 3 (foundations) are done: reading types and annotations in Part 13, type conversion in Part 14, and expressions here. The remaining one is the set of tools whose names only flashed past in those three posts. Utilities such as ClassUtils, ReflectionUtils, StringUtils and Assert, which almost every Spring class uses, and AOT, which does at build time what used to be done with reflection at run time. They are the subject of the next post, Part 16: Utilities and AOT — the Toolbox Everyone Uses, and Computing Ahead of Time.

More posts