Mert
Samsun'da TUA tarafından yapılan Astro Hackhaton yarışması başladı
Sivas'ta 18 takımın katıldığı uzay odaklı proje yarışması yapılıyor
Trabzon -Bakan Uraloğlu: Kalkınma Yolu bittiği zaman, ülkemize 10 yıllık periyotta 55 milyar dolarlık katkısı olacak
Katar ile Ukrayna füze ve İHA'larla mücadelede tecrübe paylaşımı anlaşması imzaladı
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) orFlux<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
RouterFunctionandHandlerFunctionas 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
HttpMessageConverterbeans registered for MVC will not automatically carry over to WebFlux. You need to register aCodecConfigurerinstead.
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
SecurityFilterChainwithauthorizeHttpRequests - WebFlux uses
SecurityWebFilterChainwithauthorizeExchange - Any
UserDetailsServicemust becomeReactiveUserDetailsServicein WebFlux
🚫 You cannot mix both stacks in one application. Spring Boot will auto-configure WebFlux if
spring-boot-starter-webfluxis on the classpath, even alongsidespring-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.
EntityManageris 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 thanSseEmitteron 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
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:
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:

And now let’s try without the “you’re an expert” preamble:

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









