I learned this the hard way: the migration isn't just swapping dependencies. Your filters won't work, your config needs rewriting, and you'll discover Zuul-specific assumptions buried in your code.
Step 1: Inventory Your Zuul Mess

First, document what you have:
## Your current zuul configuration
zuul:
routes:
user-service:
path: /users/**
serviceId: user-service
order-service:
path: /orders/**
serviceId: order-service
user-service:
ribbon:
listOfServers: http://user-service:8080
Catalog every custom filter (this will take longer than you think):
- Filter type (
PRE_TYPE
, POST_TYPE
, ROUTE_TYPE
, ERROR_TYPE
)
- Filter order (execution sequence matters)
- Dependencies on
RequestContext
or external services
I spent 3 days cataloging filters on my last migration because nobody documented the custom authentication filter that was buried in a utility jar. Don't make that mistake.
Step 2: Pick Your Poison (Gateway MVC vs Reactive)
Gateway MVC is easier if you want to minimize learning curve:
@Configuration
public class ServiceRouter {
@Bean
public RouterFunction<ServerResponse> userServiceRouter() {
return route()
.route(path(\"/users/**\"), http())
.filter(lb(\"user-service\"))
.before(authenticationFilter())
.after(loggingFilter())
.build();
}
}
Reactive Gateway if you don't mind learning Mono/Flux:
@Configuration
public class GatewayConfig {
@Bean
public RouteLocator customRouteLocator(RouteLocatorBuilder builder) {
return builder.routes()
.route(\"user-service\", r -> r.path(\"/users/**\")
.filters(f -> f.stripPrefix(1))
.uri(\"lb://user-service\"))
.build();
}
}
Start with MVC unless you enjoy pain. You can always migrate to reactive later, but reactive has a learning curve that will slow down your migration and make you question your life choices.
Step 3: Rewrite Your Filters (Pain Incoming)
This is where you'll lose sleep. Your Zuul filters use RequestContext
which doesn't exist in Gateway. Here's what a typical filter migration looks like:
Your existing Zuul filter:
@Component
public class AuthenticationFilter extends ZuulFilter {
@Override
public Object run() {
RequestContext ctx = RequestContext.getCurrentContext();
HttpServletRequest request = ctx.getRequest();
String token = request.getHeader(\"Authorization\");
if (!validateToken(token)) {
ctx.setSendZuulResponse(false);
ctx.setResponseStatusCode(401);
return null;
}
return null;
}
}
Gateway MVC equivalent (easier):
public static Function<ServerRequest, ServerRequest> authenticationFilter() {
return request -> {
String token = request.headers().firstHeader(\"Authorization\");
if (!validateToken(token)) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
}
return request;
};
}
Reactive Gateway equivalent (harder):
@Component
public class AuthenticationGatewayFilter implements GatewayFilter {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String token = exchange.getRequest().getHeaders().getFirst(\"Authorization\");
if (!validateToken(token)) {
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
return exchange.getResponse().setComplete();
}
return chain.filter(exchange);
}
}
Reality check: I spent 2 weeks rewriting 8 filters on my last migration, and that was with documentation. Yours will probably take longer because there's always some undocumented bullshit filter buried in a utility jar that nobody remembers writing.
Step 4: Test Without Breaking Production
Run both gateways in parallel (trust me on this):
## Traffic splitting with headers
spring:
cloud:
gateway:
routes:
- id: user-service-test
uri: lb://user-service
predicates:
- Path=/users/**
- Header=X-Test-Migration, true
Start with 1% traffic using header routing, monitor like hell, then increase gradually. I've seen teams skip this step and take down production for 2 hours when their authentication filter broke.
Step 5: The Actual Cutover
What will break (from experience):
- Request/response transformation: Gateway handles this differently than Zuul
- Error propagation: Exceptions don't bubble up the same way
- Timeouts: Gateway MVC doesn't support per-route timeouts yet
- Load balancer config: Ribbon settings don't translate directly
Monitoring you actually need:
- Response time percentiles (watch for p99 spikes)
- Error rates by service (authentication failures spike during cutover)
- Memory usage (reactive can use more heap initially)
Response times spike during JVM warm-up, then settle at better performance than Zuul if you're lucky.
Gotchas That Will Bite You
RequestContext
doesn't exist: Use exchange attributes instead. They work differently and you'll spend days figuring out the new APIs.
Filter ordering is explicit: Gateway requires explicit ordering vs. Zuul's implicit filter registration order. This will break in subtle ways.
Reactive error handling: If you go reactive, custom error handlers are needed for proper exception propagation. The default error handling swallows exceptions.
Memory usage changes: Reactive Gateway initially uses more heap during startup, then stabilizes. Monitor this.
Health checks might fail: Gateway expects different endpoints than Zuul. Your monitoring will think services are down when they're not.
Load balancer retries: Spring Cloud LoadBalancer has different retry semantics than Ribbon. Your fail-fast assumptions might be wrong.
Timeline reality check: Plan 2-3 months minimum. Every migration takes longer than expected because there's always some edge case that breaks at 3am in production.