Spring Boot Template Engine Mix: Designing a Multi‑Engine Coexistence Architecture for Production

The article explains why running multiple template engines in a Spring Boot application becomes an operational challenge, outlines a four‑layer architecture and routing strategies, provides concrete code for a custom ViewResolver, configuration, observability, deployment, testing and migration steps, and shows how to govern the process safely in production.

Cloud Architecture
Cloud Architecture
Cloud Architecture
Spring Boot Template Engine Mix: Designing a Multi‑Engine Coexistence Architecture for Production

Why Multi‑Engine Coexistence Becomes a Production Issue

Choosing a template engine is often treated as a developer preference, but in long‑lived systems it becomes an architectural governance problem. Typical constraints include:

Core transaction pages cannot be taken down during migration.

New pages must be released quickly.

Migration must be incremental over months.

Switches must be gray‑able per request, tenant, or environment and rollback within minutes.

These constraints are abstracted into four dimensions: access (controller → view name), routing (which engine renders the view), status (which pages have migrated and gray‑release strategy), and governance (cache, pre‑heat, monitoring, rollback).

Spring MVC Support for Multiple Engines

ViewResolver Chain Essence

Spring MVC abstracts template engines via the ViewResolver interface. When a controller returns a logical view name, the DispatcherServlet delegates resolution to an ordered chain of ViewResolver beans.

HTTP Request
 -> DispatcherServlet
 -> HandlerMapping / HandlerAdapter
 -> Controller returns logical view name
 -> ViewResolver chain tries to resolve
 -> concrete View renders template
 -> HTML response

Multiple engines can coexist as long as routing is explicit.

Risks of Uncontrolled Coexistence

Overlapping resolver ranges cause ambiguous parsing.

Resolver order may change unintentionally when new starters or beans are added.

Missing failure strategy leads to silent fallback to another engine.

The production solution therefore requires a clear, auditable, gray‑able routing mechanism rather than merely registering two ViewResolver s.

Three Common Routing Approaches

Prefix‑Based Routing : view names start with thymeleaf/… or freemarker/…. Simple and readable, suitable for early migration.

Dynamic Routing by Request Features : route based on tenant, channel, activity version, or gray tag. Flexible for multi‑tenant gray releases but adds rule‑engine complexity.

Metadata‑Driven Routing : maintain a template_route registry containing page code, target engine, template path, gray flag, and description. Recommended for platform‑level governance.

Target Architecture for Production

The solution is split into four layers:

+------------------------------+
                |   Controller Layer          |
                | returns pageCode/viewName    |
                +--------------+---------------+
                               |
                               v
                +------------------------------+
                | Template Route Registry      |
                | pageCode -> engine + path    |
                | tenant/env/gray rules       |
                +--------------+---------------+
                               |
               +---------------+---------------+
               |                               |
               v                               v
        +----------------------+   +----------------------+
        | Thymeleaf Resolver   |   | FreeMarker Resolver  |
        | cache / preheat      |   | cache / preheat      |
        +----------+-----------+   +----------+-----------+
                   |                       |
                   v                       v
        +----------------------+   +----------------------+
        | Thymeleaf Templates  |   | FreeMarker Templates |
        +----------+-----------+   +----------+-----------+
                   \                     /
                    \                   /
                     v                 v
                +------------------------------+
                | Observability & Governance  |
                | metrics/log/trace/audit      |
                | gray release / rollback      |
                +------------------------------+

Core Design Principles

Configurable Routing : move from hard‑coded prefixes to a centralized, configurable registry.

Explicit Template Paths : avoid fuzzy matching; each page declares engine, path, and code.

Explicit Failure : errors are logged and trigger fallback or rollback instead of silent fallback.

Governance Integration : template changes are part of the release process with gray switches, audit, monitoring, and fast rollback.

Implementation Details (Spring Boot 3)

1. Dependencies

<properties>
    <java.version>17</java.version>
    <spring-boot.version>3.3.2</spring-boot.version>
</properties>
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-thymeleaf</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-freemarker</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-actuator</artifactId>
    </dependency>
    <dependency>
        <groupId>io.micrometer</groupId>
        <artifactId>micrometer-registry-prometheus</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
</dependencies>

2. Key Configuration

server:
  port: 8080
  shutdown: graceful
spring:
  thymeleaf:
    enabled: false
    prefix: classpath:/templates/thymeleaf/
    suffix: .html
    mode: HTML
    encoding: UTF-8
    cache: true
    check-template: true
    check-template-location: true
  freemarker:
    enabled: false
    template-loader-path: classpath:/templates/freemarker/
    suffix: .ftl
    charset: UTF-8
    cache: true
    check-template-location: true
    settings:
      number_format: 0.##########
      default_encoding: UTF-8
      output_encoding: UTF-8
      locale: zh_CN
      template_exception_handler: rethrow
      log_template_exceptions: false
      wrap_unchecked_exceptions: true
management:
  endpoints:
    web:
      exposure:
        include: health,info,prometheus
app:
  template:
    preheat-enabled: true
    fallback-enabled: false
    default-engine: FREEMARKER

Important notes:

Both engines are disabled by default and manually wired to avoid uncontrolled auto‑configuration. fallback-enabled is false to enforce explicit failure.

FreeMarker uses rethrow so errors propagate to the unified handling chain.

3. Domain Model for Routing

public enum TemplateEngineType {
    THYMELEAF,
    FREEMARKER
}
public record TemplateRouteDefinition(
        String pageCode,
        TemplateEngineType engineType,
        String templatePath,
        boolean canaryEnabled,
        String description) {
    public TemplateRouteDefinition {
        if (pageCode == null || pageCode.isBlank()) {
            throw new IllegalArgumentException("pageCode must not be blank");
        }
        if (templatePath == null || templatePath.isBlank()) {
            throw new IllegalArgumentException("templatePath must not be blank");
        }
    }
}

4. In‑Memory Registry Example

public interface TemplateRouteRegistry {
    Optional<TemplateRouteDefinition> find(String pageCode, HttpServletRequest request);
}

@Component
public class InMemoryTemplateRouteRegistry implements TemplateRouteRegistry {
    private final Map<String, TemplateRouteDefinition> routes;
    public InMemoryTemplateRouteRegistry() {
        this.routes = Map.of(
            "PAGE_OPERATION_HOME", new TemplateRouteDefinition(
                "PAGE_OPERATION_HOME",
                TemplateEngineType.THYMELEAF,
                "operation/home",
                true,
                "运营首页"),
            "PAGE_ORDER_DETAIL", new TemplateRouteDefinition(
                "PAGE_ORDER_DETAIL",
                TemplateEngineType.FREEMARKER,
                "order/detail",
                false,
                "订单详情页")
        );
    }
    @Override
    public Optional<TemplateRouteDefinition> find(String pageCode, HttpServletRequest request) {
        return Optional.ofNullable(routes.get(pageCode));
    }
}

In production the registry can be backed by a config center, database, or GitOps file.

5. Unified Page Model

public record TemplatePage(String pageCode, Map<String, Object> model) {
    public static TemplatePage of(String pageCode, Map<String, Object> model) {
        return new TemplatePage(pageCode, model);
    }
}

Controllers now return a logical view name page-router and a TemplatePage object, decoupling business code from the concrete engine.

6. Custom Routing ViewResolver

@Slf4j
public class RoutingTemplateViewResolver implements ViewResolver, Ordered {
    private final TemplateRouteRegistry routeRegistry;
    private final ViewResolver thymeleafViewResolver;
    private final ViewResolver freemarkerViewResolver;
    private final MeterRegistry meterRegistry;
    private final boolean fallbackEnabled;
    public RoutingTemplateViewResolver(TemplateRouteRegistry routeRegistry,
                                       ViewResolver thymeleafViewResolver,
                                       ViewResolver freemarkerViewResolver,
                                       MeterRegistry meterRegistry,
                                       boolean fallbackEnabled) {
        this.routeRegistry = routeRegistry;
        this.thymeleafViewResolver = thymeleafViewResolver;
        this.freemarkerViewResolver = freemarkerViewResolver;
        this.meterRegistry = meterRegistry;
        this.fallbackEnabled = fallbackEnabled;
    }
    @Override
    public View resolveViewName(String viewName, Locale locale) throws Exception {
        if (!"page-router".equals(viewName)) {
            return null;
        }
        return new RoutingTemplateView(routeRegistry, thymeleafViewResolver,
                freemarkerViewResolver, meterRegistry, fallbackEnabled, locale);
    }
    @Override
    public int getOrder() {
        return Ordered.HIGHEST_PRECEDENCE;
    }
}

7. RoutingTemplateView Core Logic

@Slf4j
public class RoutingTemplateView implements View {
    private final TemplateRouteRegistry routeRegistry;
    private final ViewResolver thymeleafViewResolver;
    private final ViewResolver freemarkerViewResolver;
    private final MeterRegistry meterRegistry;
    private final boolean fallbackEnabled;
    private final Locale locale;
    public RoutingTemplateView(TemplateRouteRegistry routeRegistry,
                               ViewResolver thymeleafViewResolver,
                               ViewResolver freemarkerViewResolver,
                               MeterRegistry meterRegistry,
                               boolean fallbackEnabled,
                               Locale locale) {
        this.routeRegistry = routeRegistry;
        this.thymeleafViewResolver = thymeleafViewResolver;
        this.freemarkerViewResolver = freemarkerViewResolver;
        this.meterRegistry = meterRegistry;
        this.fallbackEnabled = fallbackEnabled;
        this.locale = locale;
    }
    @Override
    public String getContentType() {
        return "text/html;charset=UTF-8";
    }
    @SuppressWarnings("unchecked")
    @Override
    public void render(Map<String, ?> model, HttpServletRequest request, HttpServletResponse response) throws Exception {
        TemplatePage templatePage = (TemplatePage) model.get("templatePage");
        if (templatePage == null) {
            throw new IllegalStateException("templatePage is required");
        }
        long start = System.nanoTime();
        TemplateRouteDefinition route = routeRegistry.find(templatePage.pageCode(), request)
                .orElseThrow(() -> new IllegalStateException("No template route found for pageCode=" + templatePage.pageCode()));
        try {
            View delegate = resolveDelegate(route, locale);
            delegate.render(templatePage.model(), request, response);
            recordSuccess(route, System.nanoTime() - start);
        } catch (Exception ex) {
            recordFailure(route, ex);
            if (fallbackEnabled && route.canaryEnabled()) {
                View fallback = freemarkerViewResolver.resolveViewName("freemarker/fallback/error-page", locale);
                fallback.render(templatePage.model(), request, response);
                return;
            }
            throw ex;
        }
    }
    private View resolveDelegate(TemplateRouteDefinition route, Locale locale) throws Exception {
        String resolvedViewName = switch (route.engineType()) {
            case THYMELEAF -> "thymeleaf/" + route.templatePath();
            case FREEMARKER -> "freemarker/" + route.templatePath();
        };
        ViewResolver resolver = switch (route.engineType()) {
            case THYMELEAF -> thymeleafViewResolver;
            case FREEMARKER -> freemarkerViewResolver;
        };
        View view = resolver.resolveViewName(resolvedViewName, locale);
        if (view == null) {
            throw new IllegalStateException("Resolved view is null, route=" + route);
        }
        return view;
    }
    private void recordSuccess(TemplateRouteDefinition route, long costNanos) {
        Timer.builder("template.render.duration")
                .tag("pageCode", route.pageCode())
                .tag("engine", route.engineType().name())
                .register(meterRegistry)
                .record(costNanos, TimeUnit.NANOSECONDS);
    }
    private void recordFailure(TemplateRouteDefinition route, Exception ex) {
        Counter.builder("template.render.error")
                .tag("pageCode", route.pageCode())
                .tag("engine", route.engineType().name())
                .tag("exception", ex.getClass().getSimpleName())
                .register(meterRegistry)
                .increment();
        log.error("Template render failed, pageCode={}, engine={}, templatePath={}",
                route.pageCode(), route.engineType(), route.templatePath(), ex);
    }
}

8. Bean Configuration for All Resolvers

@Configuration
public class MultiTemplateEngineConfiguration {
    @Bean
    public SpringResourceTemplateResolver thymeleafTemplateResolver(ApplicationContext applicationContext,
                                                                 ThymeleafProperties properties) {
        SpringResourceTemplateResolver resolver = new SpringResourceTemplateResolver();
        resolver.setApplicationContext(applicationContext);
        resolver.setPrefix(properties.getPrefix());
        resolver.setSuffix(properties.getSuffix());
        resolver.setTemplateMode(TemplateMode.HTML);
        resolver.setCharacterEncoding(properties.getEncoding().name());
        resolver.setCacheable(properties.isCache());
        resolver.setCheckExistence(true);
        resolver.setOrder(1);
        return resolver;
    }

    @Bean("thymeleafEngineViewResolver")
    public ViewResolver thymeleafViewResolver(SpringTemplateEngine templateEngine,
                                            ThymeleafProperties properties) {
        ThymeleafViewResolver resolver = new ThymeleafViewResolver();
        resolver.setTemplateEngine(templateEngine);
        resolver.setCharacterEncoding(properties.getEncoding().name());
        resolver.setContentType("text/html;charset=UTF-8");
        resolver.setCache(properties.isCache());
        resolver.setViewNames(new String[]{"thymeleaf/*"});
        resolver.setOrder(10);
        return resolver;
    }

    @Bean
    public FreeMarkerConfigurer freeMarkerConfigurer(FreeMarkerProperties properties) {
        FreeMarkerConfigurer configurer = new FreeMarkerConfigurer();
        configurer.setTemplateLoaderPaths(properties.getTemplateLoaderPath());
        return configurer;
    }

    @Bean("freemarkerEngineViewResolver")
    public ViewResolver freemarkerViewResolver(FreeMarkerProperties properties) {
        FreeMarkerViewResolver resolver = new FreeMarkerViewResolver();
        resolver.setPrefix("");
        resolver.setSuffix(properties.getSuffix());
        resolver.setContentType("text/html;charset=UTF-8");
        resolver.setCache(properties.isCache());
        resolver.setViewNames(new String[]{"freemarker/*"});
        resolver.setOrder(20);
        resolver.setExposeSpringMacroHelpers(true);
        return resolver;
    }

    @Bean
    public ViewResolver routingTemplateViewResolver(TemplateRouteRegistry routeRegistry,
                                                   @Qualifier("thymeleafEngineViewResolver") ViewResolver thymeleafViewResolver,
                                                   @Qualifier("freemarkerEngineViewResolver") ViewResolver freemarkerViewResolver,
                                                   MeterRegistry meterRegistry,
                                                   @Value("${app.template.fallback-enabled:false}") boolean fallbackEnabled) {
        return new RoutingTemplateViewResolver(routeRegistry, thymeleafViewResolver, freemarkerViewResolver, meterRegistry, fallbackEnabled);
    }
}

9. Template Directory Layout

src/main/resources/templates
├── thymeleaf
│   ├── operation
│   │   └── home.html
│   ├── marketing
│   │   └── campaign-home.html
│   └── fragments
│       ├── header.html
│       └── footer.html
└── freemarker
    ├── order
    │   ├── detail.ftl
    │   └── list.ftl
    ├── settlement
    │   └── bill-detail.ftl
    └── fallback
        └── error-page.ftl

The hierarchy reflects business domains rather than individual developer habits.

Real‑World Migration Example: Order Detail Page

Step 1 – Legacy (FreeMarker only)

return "freemarker/order/detail";

Problems: controller tightly coupled to engine; migration requires code change and redeploy.

Step 2 – Introduce Page Code

return new ModelAndView("page-router", model)
        .addObject("templatePage", TemplatePage.of("PAGE_ORDER_DETAIL", model));

Routing still points to FreeMarker, but business logic is decoupled.

Step 3 – Add Thymeleaf Version

Place templates/thymeleaf/order/detail.html alongside the existing FreeMarker file. Registry still maps to FreeMarker; new engine can be verified in test environments.

Step 4 – Gray‑Release Routing

Update the registry to return a Thymeleaf definition when request headers indicate a gray tenant or a canary tag:

@Override
public Optional<TemplateRouteDefinition> find(String pageCode, HttpServletRequest request) {
    TemplateRouteDefinition base = routes.get(pageCode);
    if (base == null) return Optional.empty();
    String tenantId = request.getHeader("X-Tenant-Id");
    String canaryTag = request.getHeader("X-Template-Canary");
    if ("PAGE_ORDER_DETAIL".equals(pageCode) && ("tenant_gray".equals(tenantId) || "thymeleaf".equals(canaryTag))) {
        return Optional.of(new TemplateRouteDefinition(pageCode, TemplateEngineType.THYMELEAF,
                "order/detail", true, "订单详情页灰度到 Thymeleaf"));
    }
    return Optional.of(base);
}

Step 5 – Observability & Rollback

After routing switch, monitor four metrics: template.render.duration – per‑engine latency (P50/P95/P99). template.render.error – exception count.

5xx response ratio from gateway.

Key business KPIs (order conversion, click‑through, dwell time).

If error rate rises, the first action is to route back to the old engine; the fallback must be able to revert within minutes.

Performance & Scalability Considerations

Full Request Time Breakdown

Business data queries.

Aggregation and model assembly.

Template rendering.

Network transmission.

Optimizing only the rendering stage often misdiagnoses bottlenecks.

Five Practical Optimizations

Enable template cache : disable only for local development.

Pre‑heat hot templates : resolve them at startup to avoid cold‑start spikes.

Keep heavy logic out of templates : perform calculations in service layer.

Reuse fragments wisely : avoid over‑fragmentation that raises maintenance cost.

Read from cache for hot data : prevent database calls from becoming the real bottleneck.

Observability Design

Required Metrics

Render duration (P50/P95/P99) per page and engine.

Render errors (missing template, variable failure, fragment load, NPE, etc.).

Routing hit distribution (old vs new engine) for gray‑release verification.

Fallback count – indicates instability of the new engine.

Structured Logging Fields

Log each render error with:

traceId, requestUri, pageCode, engineType, templatePath, tenantId, grayTag, exceptionClass

.

Trace Span Recommendation

Wrap the rendering step in its own span so that latency can be separated from controller and downstream RPC timings.

Container & Kubernetes Deployment

Dockerfile (multi‑stage)

FROM maven:3.9.8-eclipse-temurin-17 AS builder
WORKDIR /build
COPY pom.xml .
RUN mvn -B -q dependency:go-offline
COPY src ./src
RUN mvn -B -q clean package -DskipTests

FROM eclipse-temurin:17-jre
WORKDIR /app
COPY --from=builder /build/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

Kubernetes Deployment (simplified)

apiVersion: apps/v1
kind: Deployment
metadata:
  name: template-render-app
spec:
  replicas: 3
  selector:
    matchLabels:
      app: template-render-app
  template:
    metadata:
      labels:
        app: template-render-app
    spec:
      containers:
      - name: app
        image: registry.example.com/template-render-app:20260706
        ports:
        - containerPort: 8080
        env:
        - name: JAVA_TOOL_OPTIONS
          value: "-XX:MaxRAMPercentage=70 -XX:+HeapDumpOnOutOfMemoryError"
        - name: APP_TEMPLATE_PREHEAT_ENABLED
          value: "true"
        readinessProbe:
          httpGet:
            path: /actuator/health/readiness
            port: 8080
          initialDelaySeconds: 20
          periodSeconds: 5
        livenessProbe:
          httpGet:
            path: /actuator/health/liveness
            port: 8080
          initialDelaySeconds: 30
          periodSeconds: 10
        resources:
          requests:
            cpu: "500m"
            memory: "512Mi"
          limits:
            cpu: "1"
            memory: "1Gi"

Release Strategy

Deploy code with both resolvers but keep routing to the old engine.

Verify that all pods have completed template pre‑heat and expose metrics.

During off‑peak hours, change configuration to route a small traffic slice to the new engine.

Observe metrics; if stable, gradually increase the gray percentage.

On any serious issue, immediately revert the routing config before investigating the template.

Testing Strategy

Unit Test for Registry

class InMemoryTemplateRouteRegistryTest {
    private final InMemoryTemplateRouteRegistry registry = new InMemoryTemplateRouteRegistry();
    @Test
    void shouldResolveOrderDetailRoute() {
        MockHttpServletRequest request = new MockHttpServletRequest();
        Optional<TemplateRouteDefinition> definition = registry.find("PAGE_ORDER_DETAIL", request);
        assertThat(definition).isPresent();
        assertThat(definition.get().engineType()).isEqualTo(TemplateEngineType.FREEMARKER);
    }
}

Integration Tests for Rendering

@SpringBootTest
@AutoConfigureMockMvc
class TemplateRenderIntegrationTest {
    @Autowired
    private MockMvc mockMvc;
    @Test
    void shouldRenderOrderDetailByFreemarker() throws Exception {
        mockMvc.perform(get("/orders/1001"))
                .andExpect(status().isOk())
                .andExpect(content().string(containsString("订单详情")));
    }
    @Test
    void shouldRenderOperationHomeByThymeleaf() throws Exception {
        mockMvc.perform(get("/operation/home"))
                .andExpect(status().isOk())
                .andExpect(content().string(containsString("运营首页")));
    }
}

Gray‑Release Regression Tests

Test three cases: (1) request without gray tag routes to old engine, (2) request with gray tag routes to new engine, (3) when new engine throws, fallback logic follows the configured strategy.

Performance Testing Focus

Cold‑start vs warm request latency.

Impact of scaling new pods (pre‑heat consistency).

GC, thread‑pool and connection‑pool behavior under high concurrency.

Business success‑rate before and after template switch.

Evolution Roadmap

Stage 1 – Coexistence : enable both engines, ensure resolver boundaries and basic monitoring.

Stage 2 – Migration Governance : introduce page registry, config‑center integration, gray‑release rules, audit and rollback.

Stage 3 – Platformization : provide lifecycle management, visual routing UI, multi‑environment control, dashboards, and CI pipeline for template quality checks.

Production Checklist

Verify ViewResolver order is printed and correct.

Ensure all template paths are unique across engines.

Complete pre‑heat for hot templates; readiness probe only passes after pre‑heat.

Metrics for render duration, errors, routing hit rate are wired.

Structured logs contain pageCode, engineType, traceId for fast correlation.

Gray‑rule changes have audit records; rollback procedures rehearsed.

Avoid large‑scale template switches during peak traffic.

Compare core field outputs between old and new templates to catch data mismatches.

Complex business logic resides in service layer, not in templates.

Define success criteria and rollback thresholds for each gray phase.

Conclusion

Spring Boot readily supports multiple template engines, but real‑world production use demands a systematic routing, governance, and observability framework. By decoupling page codes from engines, centralizing routing rules, enforcing explicit failures, integrating metrics and logs, and separating deployment from routing switches, teams can achieve safe, incremental migration from FreeMarker to Thymeleaf (or any future engine) without downtime.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

observabilityKubernetesSpring Bootthymeleaffreemarkerviewresolvertemplate-routing
Cloud Architecture
Written by

Cloud Architecture

Focuses on cloud‑native and distributed architecture engineering, sharing practical solutions and lessons learned. Covers microservice governance, Kubernetes, observability, and stability engineering to help your systems run stable, fast, and cost‑effectively.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.