FreshMarker is a modern Java template engine that offers a clear, type-safe API and a lightweight runtime environment for server-side HTML rendering. This article provides an overview of the FreshMarker Spring Boot Starter. An integration without boilerplate code that integrates FreshMarker into Spring Boot 4 MVC applications, offering automatic configuration, localization-aware template resolution, transparent caching, and a set of clearly defined extension points.
This project began as an AI experiment designed to test the capabilities of generated code in a strictly defined solution. Like so many experiments before it, it demonstrated the advantages and disadvantages of using AI in software development.
Getting Started
Add the starter to your Maven project. No further configuration is required. Spring Boot picks it up via the auto-configuration imports mechanism.
<dependency>
<groupId>org.freshmarker</groupId>
<artifactId>freshmarker-spring-boot-starter</artifactId>
<version>0.1.0</version>
</dependency>
Place templates under src/main/resources/templates/ with the .fm extension and return the view name from a Spring MVC controller:
<html>
<body>
<h1>Hello, ${name}!</h1>
</body>
</html>
@Controller
public class HelloController {
@GetMapping("/hello")
public String hello(Model model) {
model.addAttribute("name", "World");
return "hello"; // resolves to classpath:/templates/hello.fm
}
}
That is all it takes. The auto-configured FreshMarkerViewResolver resolves the view name, the ResourceTemplateLoader finds the file on the classpath, and FreshMarker renders the result.
Template Caching
FreshMarker parses template source into an immutable, thread-safe Template object. Re-parsing the same source on every request is wasteful; the starter avoids it by integrating with Spring’s CacheManager abstraction.
When a CacheManager bean is present and freshmarker.cache-templates=true (the default), the starter creates a CachingTemplateLoader that wraps every builder.getTemplate() call with a cache lookup. Parsed Template instances are safe to reuse across concurrent requests without any locking.
Add a cache provider and enable caching on the application class:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
@SpringBootApplication
@EnableCaching
public class MyApplication { }
The starter detects the CacheManager automatically. Any Spring Cache backend (Ehcache, Redis, Hazelcast, JCache) works the same way.
Locale-aware Templates
One of the more distinctive features of the starter is its built-in support for locale-specific template files. The request locale is resolved via the injected LocaleResolver and applied in two ways:
- The
ResourceTemplateLoaderuses the locale to select a locale-specific template file before falling back to the base template. - The
TemplateBuilderreceives the locale viawithLocale(), so number, date, currency, and collation formatting inside the template automatically reflect the request locale without any explicit template-side configuration.
Template File Resolution
The freshmarker.locale-resolution-strategy property controls which file naming convention is used. Given the view name catalogue (resolved to catalogue.fm) and the request locale de_DE:
| Strategy | Candidates tried in order |
|---|---|
suffix (default) | catalogue_de_DE.fm → catalogue_de.fm → catalogue.fm |
path | de/DE/catalogue.fm → de/catalogue.fm → catalogue.fm |
both | de/DE/catalogue.fm → de/catalogue.fm → catalogue_de_DE.fm → catalogue_de.fm → catalogue.fm |
The first existing file wins. Both strategies follow the same specificity order as java.util.ResourceBundle: language + country before language only before the undecorated fallback.
Locale Resolver Integration
The starter auto-configures an AcceptHeaderLocaleResolver and injects it into the FreshMarkerViewResolver. The view calls LocaleResolver.resolveLocale(request) explicitly, there is no implicit fallback to HttpServletRequest.getLocale() as long as a resolver is present.
# Accept any locale sent by the browser, with English as the fallback freshmarker.default-locale=en freshmarker.supported-locales=en,de,fr
# Browser sends German; resolves catalogue_de.fm if it exists curl -H "Accept-Language: de-DE,de;q=0.9" http://localhost:8080/catalogue
The auto-configured resolver is @ConditionalOnMissingBean, so replacing it with a CookieLocaleResolver or SessionLocaleResolver requires only a single bean declaration:
@Bean("localeResolver")
public LocaleResolver localeResolver(FreshMarkerProperties properties) {
CookieLocaleResolver resolver = new CookieLocaleResolver("lang");
resolver.setDefaultLocale(properties.getDefaultLocale());
resolver.setSupportedLocales(properties.getSupportedLocales());
return resolver;
}
Locale-aware Caching
Each combination of template path, locale, and charset produces an independent cache entry. The cache key format is <path>:<locale-tag>:<charset>:
catalogue.fm:de-DE:UTF-8 ← German (Germany) variant catalogue.fm:de:UTF-8 ← German generic variant catalogue.fm::UTF-8 ← locale-less entry (never collides with locale-aware entries)
MVC Example
The freshmarker-mvc-example module is a complete Spring MVC web application that brings together all of the starter’s core capabilities in a real-world scenario: caching of parsed templates, locale-sensitive rendering, and type-safe currency amounts using the FreshMarker extension for JavaMoney/Moneta. The following section walks you through the relevant sections.
Localization: Template Selection and Messages
The application supports English and German. The configuration required is minimal:
freshmarker.default-locale=en freshmarker.supported-locales=en,de freshmarker.locale-resolution-strategy=suffix
The starter automatically configures an AcceptHeaderLocaleResolver. If the browser sends Accept-Language: de-DE the ResourceTemplateLoader automatically selects the de variant:
templates/ catalogue.fm ← Fallback (Englisch) catalogue_de.fm ← German product.fm product_de.fm contact.fm contact_de.fm header.fm header_de.fm footer.fm footer_de.fm
The locale affects two levels simultaneously:
Template file: The loader selects catalogue_de.fm instead of catalogue.fm.
Formatting: The TemplateBuilder receives the locale via withLocale(locale), so that and all number and date formats are automatically rendered correctly.${p.price}
Bean validation messages are localized via a MessageSource-linked LocalValidatorFactoryBean:
@Bean
public MessageSource messageSource() {
var src = new ReloadableResourceBundleMessageSource();
src.setBasename("classpath:messages");
src.setDefaultEncoding("UTF-8");
return src;
}
@Bean
public LocalValidatorFactoryBean validator(MessageSource messageSource) {
var factory = new LocalValidatorFactoryBean();
factory.setValidationMessageSource(messageSource);
return factory;
}
# messages.properties contact.name.required=Name is required contact.email.invalid=Must be a valid email address # messages_de.properties contact.name.required=Name ist erforderlich contact.email.invalid=Bitte gib eine gültige E-Mail-Adresse ein
Caching: Configuration and Activation
This example uses Caffeine as the cache backend. Activation involves three steps.
Step 1: Dependencies (pom.xml):
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
Step 2 — @EnableCaching on the application class:
@SpringBootApplication
@EnableCaching
public class ExampleApplication { }
Step 3 — Properties:
freshmarker.cache-templates=true freshmarker.cache-name=freshmarker.templates spring.cache.type=caffeine spring.cache.caffeine.spec=maximumSize=200,expireAfterWrite=1h management.endpoints.web.exposure.include=caches
The starter detects the CacheManager bean and routes every builder.getTemplate() call through the CachingTemplateLoader. Since the cache key is composed of the path, locale tag, and character set (catalogue.fm:de:UTF-8), the German and English template objects coexist independently of one another in the same cache.
Template caches can be viewed and cleared using the exposed caches Actuator.
# Display the current status of the template cache curl http://localhost:8080/actuator/caches/freshmarker.templates # Completely clear the cache (e.g., after template deployment) curl -X DELETE http://localhost:8080/actuator/caches/freshmarker.templates
Data Model: MonetaryAmount as the Primary Entity
The product model is a Java-21 record. The price is not a BigDecimal, but a MonetaryAmount from the JSR-354 API (implementation: Moneta):
<dependency>
<groupId>org.freshmarker</groupId>
<artifactId>freshmarker-money</artifactId>
<version>3.0.0</version>
</dependency>
By installing the freshmarker-money extension, the MonetaryAmount and Currency classes from JSR-354 are made available to FreshMarker.
package org.freshmarker.example.model;
import javax.money.MonetaryAmount;
public record Product(
int id,
String name,
String category,
String description,
MonetaryAmount price, // JSR-354 / Moneta
int stock
) {}
In the repository, the instances are created using Money.of(BigDecimal, "EUR"):
new Product(1, "FreshMarker Manual", "Books", "...", Money.of(new BigDecimal("29.99"), "EUR"), 42),
new Product(4, "Mechanical Keyboard", "Electronics", "...", Money.of(new BigDecimal("129.00"), "EUR"), 5),
The value is passed directly into the template as ${p.price}, without any manual formatting in the controller.
The freshmarker-money extension registers a MonetaryAmountFormatter in the FreshMarker configuration. This allows ${p.price} to format the amount in a locale-sensitive manner, displaying 29.99 EUR for de and EUR 29.99 for en, without the template or controller needing to know anything about it.
How the Three Features Work Together
You can see how they work together in a single request:
curl -H "Accept-Language: de-DE,de;q=0.9" http://localhost:8080/catalogue
- The
AcceptHeaderLocaleResolverdeterminesde_DE. - The
CachingTemplateLoadersearches for the entrycatalogue_de.fm:de-DE:UTF-8in the Caffeine cache. - If there is a cache miss, the
ResourceTemplateLoaderselectscatalogue_de.fm(suffix strategy:de_DE→de→ fallback) and parses it. - The
TemplateBuilderretrieves the locale viawithLocale(Locale.forLanguageTag("de-DE")). - FreshMarker renders
${p.price}using theMonetaryAmountFormatterfrom freshmarker-money as29.99 EUR. - The parsed template object is stored in the cache under
catalogue_de.fm:de-DE:UTF-8; all subsequent requests hit the cache.
The Newsletter Batch Example
FreshMarker’s Template.reduce(Map) method resolves a subset of template expressions, those whose values are available now, and returns a new Template in which those expressions have been replaced by their literal values. The remaining expressions are left open for a later, second render pass.
This is particularly powerful for scenarios such as newsletter dispatch, where the template has two distinct layers of data:
- Issue-level data — fixed for the entire run: title, date, articles, company info. This layer can be resolved once, before iterating over recipients.
- Subscriber-level data — unique per recipient: first name, last name, personalised unsubscribe link. This layer is resolved once per subscriber against the already-reduced template.
The freshmarker-batch-example module demonstrates this pattern with a Spring Batch job that simulates sending a multilingual newsletter.
The template (newsletter.fm / newsletter_de.fm) contains expressions from both layers mixed together:
<!-- Resolved in step 1 (reduce) -->
<h1>${issue.title}</h1>
<p>${issue.previewText}</p>
<#list issue.articles as article>
<h2>${article.headline}</h2>
<p>${article.body}</p>
</#list>
<!-- Resolved in step 2 (process) -->
<p>Dear ${subscriber.firstName} ${subscriber.lastName},</p>
<a href="${issue.company.unsubscribeBaseUrl}?id=${subscriber.id}">Unsubscribe</a>
The NewsletterReducer performs step 1:
public Template reduce(NewsletterIssue issue, Locale locale) throws Exception {
Template template = freshMarkerConfiguration
.builder()
.withLocale(locale)
.getTemplate(Path.of("newsletter.fm"), StandardCharsets.UTF_8);
// All ${issue.*} expressions are resolved and embedded as literal text.
// ${subscriber.*} expressions remain open.
return template.reduce(Map.of("issue", issue));
}
The NewsletterSender performs step 2 for each recipient:
public RenderedNewsletter send(Template reducedTemplate, Subscriber subscriber, String subject) {
String htmlBody = reducedTemplate.process(Map.of("subscriber", subscriber));
// hand off to mail delivery service ...
return new RenderedNewsletter(subscriber, subject, htmlBody);
}
The Spring Batch job ties both steps together, grouping subscribers by locale so that the reduction (the expensive parse + partial render) runs exactly once per locale rather than once per subscriber:
for (Locale locale : subscriberRepository.findDistinctLocales()) {
// Phase 1: reduce once per locale
Template reduced = reducer.reduce(issue, locale);
// Phase 2: personalise once per subscriber
for (Subscriber subscriber : subscriberRepository.findByLocale(locale)) {
sender.send(reduced, subscriber, issue.subject());
}
}
Because FreshMarker’s Template instances are immutable and thread-safe, the reduced template can safely be shared across concurrent personalisation tasks.
Concluding Thoughts
It turns out that working with AI has its advantages. The time to market for a library like this is drastically reduced. Where implementation and testing used to take many weeks, developers can now focus much more on the library’s actual features. In this example, for instance, that means leveraging Spring Boot’s caching capabilities. AI is also helpful in generating tedious artifacts, such as documentation and examples. Unfortunately, errors repeatedly creep into AI-generated output; fixing them can be tedious but is sometimes very instructive.
I hope this Spring Boot starter proves useful to some of you out there, and I look forward to your feedback.