iPhone 18 serisinde Dinamik Ada küçülebilir

Apple’ın iPhone 18 serisinde Dinamik Ada’yı %35 oranında daraltabileceği konuşuluyor. Sızıntılar, bu değişikliğin sadece Pro modellerle sınırlı kalmayıp tüm aileye yayılabileceğini gösteriyor.

Spring MVC vs WebFlux in 2025: Which One Should You Actually Use?

Spring MVC and Spring WebFlux solve the same problem — handling HTTP requests — but through completely different concurrency models. With Spring Boot 4 and Java 25 both shipping in late 2025, the landscape has shifted significantly. Let me walk through every dimension that matters for making the right architectural decision.

Table of Contents

  • 1. Programming model: imperative vs reactive
  • 2. Framework internals: what’s actually different under the hood
  • 3. Cross-cutting concerns compared
  • 4. Context propagation & ThreadLocal
  • 5. Transactions & persistence
  • 6. Java 25 + Spring Boot 4: do we still need WebFlux?
  • 7. Decision guide

1. Programming model: imperative vs reactive

The most visible difference between Spring MVC and Spring WebFlux isn’t in the framework itself — it’s in how you write code:

  • Spring MVC uses the familiar thread-per-request imperative model.
  • WebFlux uses reactive streams via Project Reactor, where every value is wrapped in a Mono<T> (zero or one result) or Flux<T> (zero to N results).

Spring MVC:

@RestController
@RequestMapping("/users")
public class UserController {

    private final UserService service;

    @GetMapping("/{id}")
    public User getUser(@PathVariable Long id) {
        // Blocks. Thread waits for DB.
        return service.findById(id);
    }
}

Spring WebFlux:

@RestController
@RequestMapping("/users")
public class UserController {

    private final UserService service;

    @GetMapping("/{id}")
    public Mono<User> getUser(@PathVariable Long id) {
        // Non-blocking pipeline.
        return service.findById(id);
    }
}

Composing multiple async calls

Spring MVC — straightforward procedural code:

public OrderSummary getOrderSummary(Long orderId) {

    Order order = orderRepo.findById(orderId);
    User user = userService.findById(order.userId);
    List<Item> items = itemService.findByOrder(orderId);

    return new OrderSummary(order, user, items);
}

Spring WebFlux — reactive operator chains:

public Mono<OrderSummary> getOrderSummary(Long orderId) {

    return orderRepo.findById(orderId)
        .flatMap(order ->
            Mono.zip(
                userService.findById(order.userId),
                itemService.findByOrder(orderId),
                (user, items) -> new OrderSummary(order, user, items)));
}

⚠️ The reactive tax: The MVC version is plain procedural code that any Java developer can read immediately. The WebFlux version requires knowing flatMap, Mono.zip, and tuple destructuring — all just to express the same business logic. This is the learning curve that drives a lot of teams back to MVC, and it’s worth taking seriously before you commit.

Error handling

MVC — familiar try/catch:

try {
    User user = userService.findById(id);
    return ResponseEntity.ok(user);
} catch (UserNotFoundException e) {
    return ResponseEntity.notFound().build();
}

WebFlux — operator-based errors:

return userService.findById(id)
    .map(ResponseEntity::ok)
    .onErrorResume(
        UserNotFoundException.class,
        e -> Mono.just(ResponseEntity.notFound().build())
    );

💡 WebFlux also supports a fully functional router model with RouterFunction and HandlerFunction as an alternative to annotations — another concept on the pile.

2. Framework internals: what’s actually different under the hood

Beneath the annotation layer, the two stacks are built on completely different foundations.

Layer Spring MVC Spring WebFlux
Entry point DispatcherServlet DispatcherHandler
HTTP abstraction HttpServletRequest / Response ServerWebExchange
Filter API OncePerRequestFilter WebFilter (returns Mono<Void>)
Security SecurityFilterChain SecurityWebFilterChain
Request context RequestContextHolder (ThreadLocal) ServerWebExchange (Reactor Context)
Foundation Servlet API Reactive Streams (Project Reactor)
Default server Tomcat / Jetty Netty

📌 Spring Boot 4 note: Undertow has been dropped from Spring Boot 4 because it’s not yet compatible with Servlet 6.1. So the old trio of Tomcat / Jetty / Undertow is now Tomcat / Jetty on the MVC side.

DispatcherServlet vs DispatcherHandler

Spring MVC routes everything through DispatcherServlet, which implements the Servlet API and blocks the calling thread while the response is assembled.

WebFlux replaces this with DispatcherHandler, which implements WebHandler — every request is a reactive pipeline from first byte to last.

Jackson serialization

Both stacks default to Jackson 3 in Spring Boot 4, but the codec layer differs. MVC uses HttpMessageConverter implementations; WebFlux uses reactive HttpMessageReader / HttpMessageWriter.

⚠️ Custom HttpMessageConverter beans registered for MVC will not automatically carry over to WebFlux. You need to register a CodecConfigurer instead.

Filters

MVC — Servlet filter:

@Component
public class LoggingFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(
            HttpServletRequest req,
            HttpServletResponse res,
            FilterChain chain) throws Exception {

        log.info("→ {}", req.getRequestURI());
        chain.doFilter(req, res);
        log.info("← {}", res.getStatus());
    }
}

WebFlux — WebFilter:

@Component
public class LoggingFilter implements WebFilter {

    @Override
    public Mono<Void> filter(
            ServerWebExchange exchange,
            WebFilterChain chain) {

        log.info("→ {}", exchange.getRequest().getPath());
        return chain.filter(exchange)
            .doFinally(s -> log.info("← done"));
    }
}

Security

Spring Security 7 (Spring Boot 4) ships separate auto-configurations for each stack:

  • MVC uses SecurityFilterChain with authorizeHttpRequests
  • WebFlux uses SecurityWebFilterChain with authorizeExchange
  • Any UserDetailsService must become ReactiveUserDetailsService in WebFlux

🚫 You cannot mix both stacks in one application. Spring Boot will auto-configure WebFlux if spring-boot-starter-webflux is on the classpath, even alongside spring-boot-starter-web. Pick one and explicitly exclude the other.

3. Cross-cutting concerns compared

Concern Spring MVC Spring WebFlux
Exception handling @ExceptionHandler, @ControllerAdvice @ExceptionHandler, WebExceptionHandler
Validation @Valid / BindingResult @Valid / Mono<T>.handle()
CORS @CrossOrigin / CorsConfiguration @CrossOrigin / CorsWebFilter
Streaming / SSE SseEmitter (thread occupied) Flux<ServerSentEvent> (native, zero threads)
WebSocket WebSocketHandler + Tomcat WebSocketHandler + Netty (lower overhead)
Observability Micrometer — automatic with Tomcat span Micrometer — requires Mono.contextWrite propagation
Jackson config WebMvcConfigurer.configureMessageConverters WebFluxConfigurer.configureHttpMessageCodecs

4. Context propagation & ThreadLocal

This is one of the most painful operational differences between the two stacks. In Spring MVC, every request lives on a dedicated thread from start to finish. Anything you put in a ThreadLocal — user identity, trace ID, request metadata — is available anywhere in the call stack via RequestContextHolder.

In WebFlux, a single request can hop across many threads managed by Netty’s event loop. ThreadLocal values simply disappear between operators.

MVC — RequestContextHolder just works

// Works anywhere in the MVC call stack
RequestAttributes attrs =
    RequestContextHolder.getRequestAttributes();

String traceId = (String) attrs.getAttribute(
    "X-Trace-Id", SCOPE_REQUEST);

WebFlux — Reactor Context must be threaded explicitly

// Store in Reactor Context at filter level
chain.filter(exchange)
    .contextWrite(Context.of(
        "traceId",
        exchange.getRequest().getHeaders()
            .getFirst("X-Trace-Id")));

// Read from Reactor Context downstream
Mono.deferContextual(ctx -> {
    String traceId = ctx.get("traceId");
    // ...
});

Spring Security context propagation

MVC automatically propagates the SecurityContext via SecurityContextHolder (ThreadLocal-backed).

WebFlux uses ReactiveSecurityContextHolder, which stores the context in the Reactor subscriber context.

⚠️ Calling SecurityContextHolder.getContext() from inside a WebFlux handler returns empty. This is one of the most common bugs when people migrate or mix the stacks.

ScopedValue

Java 25 finalizes ScopedValue (previewed from JDK 20 through JDK 24, finalized in JDK 25), which replaces ThreadLocal in virtual-thread code. Unlike ThreadLocal, a ScopedValue is immutable within its scope, has no memory leak risk, and composes naturally across virtual threads.

Legacy ThreadLocal approach:

private static final ThreadLocal<String> TRACE_ID = new ThreadLocal<>();

// Risk: must be cleaned up manually
// Risk: memory leaks with thread pools
TRACE_ID.set("abc-123");
try {
    processRequest();
} finally {
    TRACE_ID.remove();
}

Java 25 — ScopedValue:

private static final ScopedValue<String> TRACE_ID =
    ScopedValue.newInstance();

// Automatically scoped — no cleanup needed
// Immutable — no accidental mutation
// Works correctly across virtual threads
ScopedValue.where(TRACE_ID, "abc-123")
    .run(this::processRequest);

5. Transactions & persistence

Transaction management is arguably where WebFlux creates the most friction, because the relational database ecosystem has been blocking-first for decades.

MVC — @Transactional just works

In MVC, @Transactional binds a JDBC connection to the current thread via PlatformTransactionManager and TransactionSynchronizationManager (both ThreadLocal-based). Every JPA, JDBC, or Spring Data JPA call on the same thread participates in the transaction automatically.

@Service
public class OrderService {

    @Transactional
    public Order placeOrder(OrderRequest req) {
        Order order = orderRepo.save(new Order(req));
        inventoryService.reserve(req);
        paymentService.charge(req);
        return order; // auto-commit or rollback
    }
}

WebFlux — R2DBC required, reactive operators mandatory

@Service
public class OrderService {

    @Transactional // uses R2dbcTransactionManager
    public Mono<Order> placeOrder(OrderRequest req) {
        return orderRepo.save(new Order(req))
            .flatMap(order ->
                inventoryService.reserve(req)
                    .then(paymentService.charge(req))
                    .thenReturn(order));
    }
}

🚫 JPA does not work with WebFlux. EntityManager is fundamentally synchronous and ThreadLocal-based. Using JPA in a WebFlux application causes connection leaks and correctness bugs. You must use R2DBC with Spring Data R2DBC repositories. Most legacy codebases that mix JPA + WebFlux are accidentally blocking the event loop — and end up with worse throughput than plain MVC.

R2DBC warning

R2DBC lacks feature parity with JPA: no first-level cache, no lazy loading, no JPQL, no criteria API. Complex queries require raw SQL via DatabaseClient. There have been notable production bugs too — for instance, r2dbc-pool 1.0.2 had a well-known issue where read-only transactions on pooled connections failed silently, fixed in 1.0.3+.

The killer argument for MVC + virtual threads

With virtual threads (Java 21+ / Spring Boot 3.2+), blocking JDBC calls no longer pin a platform thread. The JVM unmounts the virtual thread during I/O, freeing the carrier thread for other work. You get near-reactive throughput while keeping JPA, @Transactional, and the entire Spring Data JPA ecosystem intact.

6. Java 25 + Spring Boot 4: do we still need WebFlux?

What virtual threads actually change

The original rationale for WebFlux was simple: platform threads cost around 1 MB each, so you can realistically run only a few hundred before the JVM starts struggling. Reactive programming multiplexed thousands of concurrent requests over a small event-loop thread pool.

With virtual threads, that constraint disappears. The JVM can manage millions of virtual threads, each costing only a few KB, and automatically parks them during I/O without blocking the underlying carrier thread.

Enabling virtual threads in Spring Boot — one config line:

# application.yaml
spring:
  threads:
    virtual:
      enabled: true
# That's it. Tomcat now runs every request on a virtual thread.
# Your existing imperative code is unchanged.

Structured Concurrency (still in preview in Java 25) — parallel calls, imperative style:

// Note: Structured Concurrency is still a preview feature as of Java 25
// Enable with --enable-preview
try (var scope = StructuredTaskScope.open()) {

    var user  = scope.fork(() -> userService.get(id));
    var prefs = scope.fork(() -> prefService.get(id));

    scope.join();
    return combine(user.get(), prefs.get());
}

This is the reactive Mono.zip() equivalent — plain Java, debuggable with standard tools, readable by any developer on the team. Worth noting that it’s still a preview API, so it’s not production-stable in the same way ScopedValue now is.

WebFlux still collapses with blocking JDBC. If your reactive pipeline calls a blocking driver, you block the Netty event-loop thread — far worse than blocking a virtual thread. This is the most common production mistake people make when adopting WebFlux without fully committing to the reactive stack.

Where WebFlux still belongs

Despite virtual threads making reactive unnecessary for most applications, there are genuine niches where WebFlux wins:

  • Streaming workloads — SSE feeds, real-time data pipelines, WebSocket at high connection counts. Flux<ServerSentEvent> with Netty is far more efficient than SseEmitter on Tomcat.
  • Fully reactive I/O stacks — teams already on R2DBC, reactive MongoDB, and Reactive Redis who want consistent non-blocking semantics end to end.
  • Backpressure requirements — systems where consumers must signal capacity to producers. Reactor’s backpressure operators have no MVC equivalent.
  • Event-driven microservices — services primarily reacting to external streams (Kafka, RSocket) rather than synchronous HTTP.

Performance comparison

Workload MVC + Virtual Threads WebFlux
Blocking I/O (JDBC) ✅ Excellent ❌ Collapses
Non-blocking I/O (R2DBC) ✅ Good ✅ Excellent
SSE / streaming ⚠️ Adequate ✅ Excellent
CPU-bound work ✅ Good ⚠️ Needs scheduler tuning
Developer productivity ✅ Excellent ⚠️ Steep learning curve

7. Decision guide

Choose MVC + Virtual Threads when…

  • Your team knows imperative Java
  • You use JPA / Hibernate / Spring Data JPA
  • You have an existing MVC codebase
  • Your app is primarily REST with JDBC I/O
  • You want maintainable, debuggable code
  • You’re on Java 21+ with Spring Boot 3.2+

Choose WebFlux when…

  • Your app streams data (SSE, WebSocket at scale)
  • You’re fully committed to R2DBC / reactive drivers
  • You need native backpressure semantics
  • Your team is experienced with Project Reactor
  • You’re building event-driven microservices
  • All your I/O is non-blocking end to end

The verdict

For most enterprise Java applications being built today, Spring MVC + Virtual Threads is the right default. You get near-reactive throughput with imperative code, full JPA compatibility, simpler security configuration, correct @Transactional behavior, easier debugging, and straightforward ScopedValue-based context propagation on Java 25.

WebFlux remains the best tool for streaming-first and fully-reactive architectures — but that is now a deliberate, specific architectural choice, not the default path to performance.

Use case Recommended
REST APIs MVC + Virtual Threads
Streaming / SSE WebFlux
JDBC / JPA MVC + Virtual Threads
R2DBC only Either
WebSocket at scale WebFlux
Greenfield API MVC + Virtual Threads

Have you migrated from WebFlux to MVC + Virtual Threads in production? What was your experience? Drop a comment below.

Wrangling GenAI: Optimising prompts for Visual Tasks

Messy living room with laundry covering sofa; boxes and bags scattered on the floor

When I wrote the first version of the magic cleaning tasks generator for Hounty I wasn’t particularly happy with it, but I thought that this is as good as it gets with Generative AI APIs and let it be till I have time to train my own model. Well, today I proved myself wrong and I want to show you exactly how I managed to drastically improve performance by adjusting the prompt in a few inconventional ways.

I encourage you to follow along and try the prompts yourself. I used Gemini and Claude, but pretty much any chat AI app or API should work. And if you have never worked with GenAI – that’s no problem – you don’t have to have any prior knowledge to follow along.

The Task

Here is a picture of my living room this morning:

Messy living room with laundry covering sofa; boxes and bags scattered on the floor

It looks like this a few days a week and every time it’s a chore to get started. I do rely on Hounty to tell me what to do and where to start. So let’s try and replicate the core functionality with nothing but prompts and see how to improve them.

Take one: You are an expert

You’ve probably seen the advice to start your prompt with “you are an expert in …”. It sounds like a good idea, but let’s try and see what it actually does.

Paste an image of a messy room (yours or mine) into your AI chat and prompt this:

You are a home cleaning expert. Create a cleaning todo list of 3-5 most impactful tasks

Here’s what I got from Gemini:
Gemini trying to be an expert and responding with a lengthy and somewhat condecending list of tasks like

And now let’s try without the “you’re an expert” preamble:
Gemini responding with a much shorter and more grounded list of tasks like

The second output is about half the size of the “expert” one and I find it much more actionable and clear: “Clear the Floor” vs “Clear the Floor Transit Zones”. So why does this happen?

Turns out, giving AI a title doesn’t unlock more knowledge, but instead it forces it to act like an expert or, in other words, researchers found that Expert Personas Improve LLM Alignment but Damage Accuracy.

Not to say that personas have no use, but in majority of cases adding “you are an expert” would result in a confident tone and bloated output. Which means you’ll burn more tokens for no benefit.

Skipping it works better.

Take two: Detailed prompt

On the first glance, a common advice to describe what you want in as many details as possible makes sense, so let’s give it a try.

Same image, more explicit prompt:

Look at this image. List every visible object. For each object, determine whether it is out of place based on what should be in a Living Room. Consider laundry, dishes and clutter specifically. Return your findings in a structured list of 3-5 most impactful cleaning items as JSON.
Example:
[{“task”:”Throw away newspapers”, “advice”:”Recycle as newspapers”}]

Gemini returns a long output describing everything it sees on the image and only then providing requested JSON

Here’s the json it returned, since it didn’t fit into the first screen:

[
  {
    "task": "Fold and put away laundry from the sofa",
    "advice": "Sort by owner or garment type and move to the bedroom or closet to regain seating space."
  },
  {
    "task": "Stow reusable shopping bags",
    "advice": "Empty any remaining contents and store the Lidl bags in a pantry, kitchen drawer, or car trunk."
  },
  {
    "task": "Clear the floor of miscellaneous items",
    "advice": "Pick up the pink hairbrush and the black tub; return the brush to the bathroom and store the tub in a utility area."
  },
  {
    "task": "Declutter the windowsill and cat tree base",
    "advice": "Move non-decorative items and loose papers to a dedicated desk or filing system to improve natural light flow."
  }
]

And now try a simpler version:

Create a todo list of 3-5 most impactful cleaning tasks. Return as JSON {title,advice}

Gemini returns just the JSON array with tasks

Here’s the resulting json in full for comparison:

[
  {
    "title": "Clear the Sofa Surface",
    "advice": "Fold or hang the large pile of clothing on the couch. Clearing this central furniture piece will immediately make the room feel functional and less overwhelmed by 'clutter weight'."
  },
  {
    "title": "Consolidate Floor Items",
    "advice": "Gather the loose items on the rug and the shopping bags by the door. Use the empty black bin shown in the foreground to quickly corral miscellaneous objects that don't have a dedicated home yet."
  },
  {
    "title": "Clear the Windowsill and Radiator",
    "advice": "Remove the papers and pink organizer from the windowsill. In a bright room, clear horizontal surfaces near windows reflect more light and create a much cleaner visual line."
  },
  {
    "title": "Reset the Dining Area",
    "advice": "Tuck the dining chairs neatly under the table and remove any small items from the tabletop. Aligning the furniture creates a sense of order that anchors the rest of the open space."
  }
]

Both prompts produced a similar list, but the first one added a lot of noise, which would not only made it much harder to use in an API, but would also cost more since it used more input and output tokens.

And the pink brush the first list mentions? It’s a cat brush, it doesn’t belong in the bathroom – it belongs in a box with cat stuff. Skipping that detail made the second list appear more precise.

So why did the detailed prompt backfire?

Because the second prompt gives the model room to reason. We’ve stated the goal clearly and let it do its job. When we provide too many details in the context LLM experiences effects similar to overtraining a model. In other words:

Overtraining (often synonymous with overfitting) occurs when a machine learning model learns the training data too well, capturing noise and specific details rather than general patterns.

By giving the LLM too many details we limit the ways it can solve the problem and thus as a general rule – less is more. You can always add or clarify something later.

And once again, less tokens spent!

Take three: Structured data

Now that we have the tasks in place it would be nice to add the coordinates of the areas on the image which illustrates each task.

Let’s try and enchance the prompt to return just that:

Identify 3-5 most impactful cleaning tasks and return ONLY JSON{task,x,y,w,h} mapping tasks with the areas on the image where x,y are top-left corner pixels of the bounding box and w,h is width and height

Adding “ONLY JSON” was needed to make sure that LLM doesn’t interpret “do this and then that” as two separate requests and spits out text and JSON.

Here’s the result:
Gemini returning a JSON with additional

Hm, I didn’t ask for a “label”.

We could keep patching the JSON prompt, but there’s a better tool for the job – CSV(comma-separated values).

Try this adjusted prompt:

Identify 3-5 most impactful cleaning tasks and return ONLY CSV(task,x,y,w,h) mapping tasks with the areas on the image where x,y are top-left corner pixels of the bounding box and w,h is width and height

And here’s what I got:
Gemini returning clearly formatted output without any extra fields

CSV is just as easy to parse as JSON, but turns out it produces more reliable output with less tokens. I think it might have to do with a fact that JSON requires AI to add something around values and it can occasionally go overboard, whereas CSV is just text with values as is. LLM native, if you must.

So ditch JSON, use CSV and alike. The only gotcha to keep in mind is that comma in the text might break the output, so it might be worth asking LLM to escape strings in the output.

Take four: Spatial coordinates

In the previous step we asked LLM to provide the coordinates of the top left corner of the area corresponding to each task, let’s see how it fared.

To test it I prompted Gemini and Claude to create an app. You can use the resulting artifact or try this prompt to create one yourself:

Create a small frontend app that would ask user to choose an image and paste a CSV(task,x,y,w,h) with tasks and areas on the image in either absolute pixels (add switch) or normalized coordinates (0.0 – 1.0) as well as x,y being either top-left or center of the box

Let’s see how our original prompt with absolute pixel coordinates performed:

Image overlaid with tasks; areas are about 30% off

Meh, stupid AI, can’t even outline a box properly, right?!

Now let’s try to adjust the prompt to return normalized coordinates instead:

Identify 3-5 most impactful cleaning tasks and return ONLY CSV(task,x,y,w,h) mapping tasks with the areas on the image where (x,y) are coordinates of the top-left corner of the bounding box and x,y,w,h are normalized floats between 0.0 and 1.0 (fraction of image dimensions). Box must stay within image: x-w/2 >= 0, x+w/2 <= 1, y-h/2 >= 0, y+h/2 <= 1

I added the last sentence because Gemini kept returning absolute values (way more than one) for the y coordinate.

Here’s how the resulting overlay looks:

Image overlaid with tasks that are much closer to where they should be, but still off-center

Not too bad, but still a little off.

And now for the last trick, let’s try and switch top-left corner to a center instead:

Identify 3-5 most impactful cleaning tasks and return ONLY CSV(task,x,y,w,h) mapping tasks with the areas on the image where (x,y) are coordinates of the center of the bounding box and x,y,w,h are normalized floats between 0.0 and 1.0 (fraction of image dimensions).Box must stay within image: x-w/2 >= 0, x+w/2 <= 1, y-h/2 >= 0, y+h/2 <= 1

Voilà! Almost as good as Hounty:

Image overlaid with tasks in the correct areas

If you’re wondering why LLMs work better with normalized centered values – it boils down to how the original data that model was trained on looked like.

Asking a vision model to produce output in it’s native 0-1 centered way makes it easier for them to give you what you asked for than to perform operations on the go.

“But what if I need a different format?”- you might ask.

I hear you, I also wouldn’t want to release a new app every time I’m making an update to my API – the app in my case expects absolute values returned as json.

And this is where the plain old geometry comes in handy!

If GenAI produces a reliable machine-readible output (like CSV) you can take that output and transform it with a little bit of regular programming into whatever format you’d like.

As a rule of thumb: if you can do something without AI – it would be cheaper and more reliable to do it without AI.

Cool, but how do I ship my prompts?

Great question! We spent this whole article in the chat, that’s not something you can just use or can you?

And the answer is: yes you can! GenAI APIs are pretty much standardised and you can write the same prompts as you do in the chat and send them via REST API just like you’d do for any other service.

Almost any cloud provider nowadays has their managed Generative AI API; Claude, Gemini & co also offer that, and you can even host or run your own model with Ollama.

AI might have changed software development quite a bit, but in the end of the day it is still based on the same principles and sheer logic.

There’s no perfect prompt like there’s no perfect code, but every iteration gets you a little bit closer to it.

Oh and I did clean up when I finished updating the APIs 🙂
Same living room, but clean

5 Pain Points Wagtail Users Face with Multilingual Sites

Wagtail home page with language switcher

Wagtail CMS has solid building blocks for multilingual websites. It has a Locale model, TranslatableMixin, and i18n_patterns for URL routing. The wagtail-localize package adds translation workflows. It also supports machine translation.

When I read Discussion #13693, I noticed clear patterns. Maintainers and users shared their real-world experiences with i18n. Here are the five biggest pain points.

1. Links That Switch Your Language

When you call .localized on a page, it should return the translated version. But if no translation exists, it returns the source-language page instead of None. This causes menus and links to switch the user into a different language.

Many users reported this problem. One developer wrote custom _or_none methods to work around it. This should be built into Wagtail’s core.

2. Sitemaps That Forget Other Languages

Wagtail’s sitemap generator only lists pages in the default locale. It does not add xhtml:link alternate entries or x-default values. This hurts SEO. Search engines can’t find the translated pages.

3. Search That Only Works in One Language

Wagtail’s search backend does not support many locales. You cannot configure different NLP dictionaries for different languages. The only choice is to turn off language-specific processing. This leads to poor search results for everyone.

4. Page Tree Bugs in Wagtail-Localize

I experienced this myself. The “Include subtree” translation feature makes child pages. However, it does not update the parent’s numchild metadata. This makes translated pages invisible in the admin. I reported it as issue #773 and fixed it with Page.fix_tree().

5. No Way to Tell Source from Translation

Wagtail’s TranslatableMixin doesn’t show if a page is a source or a translation. The has_translation attribute returns True even on translated pages, which is confusing. Developers need a clear way to check if a page is the original or a translated copy.

Wagtail home page with language switcher

What This Means

These are not edge cases. They affect every multilingual Wagtail site. The good news is the community has clearly identified these problems. Several of them have simple solutions.

I am proposing to work on fixes for items 1, 2, and 4 as part of my Google Summer of Code project. You can see my demo site at sushanthaaa.pythonanywhere.com and the source code on GitHub.

Read my other post: How I Found a Hidden Bug in Wagtail-Localize

Sprint 2 Retrospective: Content Sourcing & Provenance

Sprint 2 Retrospective: Content Sourcing & Provenance

Introduction

Sprint 2 of the ORCHESTRATE platform built a complete content sourcing pipeline with cryptographic provenance guarantees. Where Sprint 0 laid the foundation and Sprint 1 improved infrastructure quality, Sprint 2 tackled the core challenge: how do you ingest content from diverse sources, verify its trustworthiness, and maintain an auditable chain from source to publication?

This is the third post in our sprint retrospective series:

What We Built

Sprint 2 delivered 21 feature + verification tickets across 7 stories with 0 blocked items, adding 17 new service modules:

Epic Focus Tickets Key Services
OAS-043 Content Sourcing Pipeline 4 rss-aggregator, web-crawler, youtube-extractor, source-registry
OAS-044 Source Trust & Dedup 3 trust-scorer, dedup-engine, trust-degrader
OAS-045 Provenance Chain 5 atom-decomposer, citation-verifier, merkle-attestor, provenance-query
OAS-046 Quality Gates 3 quality-rubric, provenance-verifier, quality-gate
OAS-047 Source Snapshots 3 source-snapshot-capture, snapshot-integrity-verifier, snapshot-version-manager
OAS-079 Publishing Verification 3 Dev.to API verify, format test, repair assessment

Test progression: 925 → 1637 tests across 55 → 97 test files.

Architecture: The Provenance Pipeline

The content flows through a staged pipeline where each stage has independent tests and clear interfaces:

Source Adapter → Trust Scoring → Atom Decomposition → NLI Verification → Quality Gate → Provenance Chain → Snapshot

Each stage uses the Result pattern (Sprint 1 Decision D2) for composable error handling.

How AI Participated

Every ticket was executed through Documentation-Driven Test-Driven Development (DD TDD) with 11 active AI personas:

Persona Role Sprint 2 Focus
Content Curator Content Strategist Sourcing strategy, YouTube extraction, quality rubrics
Guard Ian Security Engineer Trust scoring, Merkle attestation, provenance verification
Api Endor Backend Developer Web crawler, provenance query API
Query Quinn Database Architect Source registry, SimHash dedup engine
Archi Tect Solution Architect ContentAtom schema, atom decomposition, quality gate integration
Pip Line DevOps Engineer RSS aggregator, snapshot capture
React Ive Frontend Developer Blog format verification, provenance metadata rendering
Aiden Orchestr AI Orchestration NLI citation verification
Tess Ter QA Engineer Publishing verification, snapshot integrity, version management
Scrum Ming Scrum Master Delivery coordination, sprint metrics
Owen Pro Product Owner Product strategy, Sprint 3 prioritization

Key Decisions for Sprint 3

The retrospective ceremony produced 7 decisions (up from 5 in Sprint 1):

  1. D1: Production Validation — Run full sourcing→trust→provenance→quality→publish pipeline with real feeds from 4 LinkedIn pages. Owner: Owen Pro. Priority: HIGH.
  2. D2: Unified External Configuration — Environment-variable timeouts and basic retry for all source adapters. Owner: Pip Line. Priority: MEDIUM.
  3. D3: Content Normalization — Design ContentIngestionEnvelope schema for unified adapter output. Owner: Content Curator. Priority: HIGH.
  4. D4: Minimal Atom Versioning — Add supersedes_atom_id field only. Temporal validity deferred. Owner: Archi Tect. Priority: MEDIUM.
  5. D5: CI Performance Monitoring — Track test execution time with 60s alert threshold. Owner: Tess Ter. Priority: LOW.
  6. D6: Health Dashboard Extension — Add content pipeline panel with source counts and trust scores. Owner: React Ive. Priority: MEDIUM.
  7. D7: Async NLI Queue — Design async verification with configurable concurrency. Owner: Aiden Orchestr. Priority: MEDIUM.

Lessons Learned

  1. Pipeline Architecture Works: The staged pipeline pattern (source→trust→atom→verify→gate→chain→snapshot) enables independent testing and clear interfaces. Each service can be developed, tested, and deployed independently. This pattern should be replicated for V3 content types.

  2. Disagreements Produce Better Decisions: Content Curator wanted more source types; Guard Ian wanted stricter trust gates. The resulting decision — validate existing sources before expanding — was better than either position alone. Preserving tension is more valuable than seeking consensus.

  3. Improvement Loop Takes One Sprint: Sprint 1 identified 5 issues. Sprint 2 fixed all 5. The retro ceremony is a real improvement mechanism, not documentation theater.

  4. Specific Acceptance Criteria Drive Implementation: Sprint 1 decisions with specific criteria (e.g., “create shared-fixtures.test.ts with SENSITIVE_PATTERNS_FIXTURE”) were implemented more faithfully than vague ones.

What Failed or Surprised Us

  • Hardcoded configuration drift: Both Sprint 1 and Sprint 2 introduced hardcoded values under delivery pressure (startup thresholds, trust score thresholds, API timeouts). This is now identified as a systemic pattern requiring a unified configuration story.
  • In-memory scaling limits: SimHash dedup index and synchronous NLI verification both revealed scaling bottlenecks that will need persistence and async processing before production workloads.
  • Test execution time growth: Test suite grew from ~15s to ~24s as test count nearly doubled (925→1637). Still well within acceptable range, but CI monitoring (D5) is proactive prevention.
  • Source adapter output divergence: Four source types each produced slightly different output structures, complicating downstream processing. This motivated D3 (ContentIngestionEnvelope).

Sprint 1 Decision Closure

All 5 Sprint 1 retro decisions were implemented and verified:

Decision Story Status Evidence
D1: Shared Utilities OAS-093 CLOSED shared-fixtures.test.ts, devto-test-utils.ts
D2: Result Type Migration OAS-094 CLOSED result-boundary-adr.test.ts, all Sprint 2 services use Result
D3: Migration Framework OAS-095 CLOSED migration-runner.test.ts, forward-only numbered migrations
D4: Structured Observability OAS-096 CLOSED health-dashboard-refresh.test.ts, auto-refresh with pause/resume
D5: Path Convention OAS-097 CLOSED path-convention.test.ts, ESLint rule, service-conventions.md

This marks the second consecutive sprint with 100% decision follow-through (Sprint 0: 3/3, Sprint 1: 5/5).

Three-Sprint Trajectory

Metric Sprint 0 Sprint 1 Sprint 2 Trend
Tests ~400 925 1637 IMPROVED
Test Files ~42 55 97 IMPROVED
Service Modules 1 5 22 IMPROVED
Blocked Items 0 0 0 STABLE
Completion Rate 100% 100% 100% STABLE
Publishing healthy healthy healthy (3x NO_REPAIR) STABLE
Retro Decisions 3 5 7 IMPROVED

What’s Next: Sprint 3 Preview

Sprint 3 priorities:

  • Production validation (D1) — run the full pipeline with real content from 4 LinkedIn pages
  • Content normalization (D3) — unified ContentIngestionEnvelope before adding more source types
  • V3 inception — YouTube channels, podcasts, audio narration, AI news generation
  • Per-category trust thresholds — configurable by source type

The 25-staff AI agency capacity goal requires normalizing the content pipeline first, then expanding.

Provenance

This blog post demonstrates the provenance principles built in Sprint 2. Every claim above traces to specific test evidence:

Field Value
Sprint Sprint 2 — Content Sourcing & Provenance
Author ORCHESTRATE AI Team (11 personas)
Methodology DD TDD — Documentation-Driven Test-Driven Development
Verified 2026-03-28
Test Evidence 1708 tests across 98 files, including 5 retro test files (OAS-078-T1 through T5)
Source Trust Score Self-assessed: HIGH (all claims cite test output or code artifacts)
Merkle Attestation Not applicable to blog post itself — Merkle attestation applies to sourced content atoms
Content Atoms This post decomposes into ~25 claim-level assertions, each traceable to a test file
NLI Confidence N/A — claims are first-party observations, not third-party citations
Temporal Claims All metrics verified against vitest runner output at sprint close
Data Sensitivity Checked — no API keys, credentials, endpoints, or PII in post
Memory Citations OAS-078-T1 work artifacts, OAS-078-T2 persona context, OAS-078-T3 ceremony, OAS-078-T4 summary
Cross-Sprint References Sprint 0 blog (dev.to/tmdlrg), Sprint 1 blog (dev.to/tmdlrg)

GPS Provenance Markers

Provenance Chain ID: prov-sprint2-retro-blog-20260328
Attestation Type: SELF_ATTESTED (first-party content)
Chain Length: 5 (artifacts → context → ceremony → summary → blog)
Integrity Status: VERIFIED (all source tests pass, 1708/1708 green)
Last Verified: 2026-03-28

Generated by ORCHESTRATE Agile Suite v2.0 — Content Sourcing & Provenance Sprint