1. Why (and When) to Split Into Services
Everything since Week 1 has lived in one Spring Boot application: one JAR, one database, one deploy. That's not a beginner's compromise — a well-built monolith is the right architecture for most teams, most of the time. Splitting it into services is a trade, not an upgrade, and it's worth being honest about both sides before you do it to the Task API.
What you gain
Independent deployability is the real prize: a team can ship changes to one service without coordinating a release with every other team. Independent scaling follows — if notification-sending is CPU-heavy and task-reading is not, you can scale each to its own load instead of scaling the whole monolith to match its hungriest part. Smaller codebases are also easier for a new engineer to hold in their head, and a crash in one service doesn't necessarily crash the others.
What it costs you
Every function call that used to be a direct, in-process method call — fast, type-checked at compile time, and either it runs or it throws — becomes a network call: slower, able to fail in ways a method call can't (timeouts, partial responses, the other service being mid-deploy), and invisible to the compiler. Data that used to live in one transactional database now lives in two, which means you give up easy consistency and have to design deliberately around it (a theme that runs through this whole module). And you inherit real operational overhead: more services to deploy, monitor, version and debug, plus the question of how they find and authenticate to each other.
Before (Weeks 1-8) After (Weeks 9-10)
┌─────────────────┐ ┌─────────────────┐ ┌──────────────────────┐
│ Task Service │ │ Task Service │ --> │ Notification Service │
│ (tasks + emails │ --> │ (tasks only) │ │ (sends emails/SMS) │
│ in one app) │ └─────────────────┘ └──────────────────────┘
└─────────────────┘
The Task service you've been building gets a neighbor: a small Notification service whose only job is sending a notification when a task is completed. It's a deliberately thin example — in a real system you'd want a stronger reason than "notifications" to pay the distributed-systems tax — but it's exactly heavy enough to demonstrate every pattern in this week and next without drowning in unrelated business logic.
If you can't name a specific scaling, ownership, or deployment problem the split solves, a monolith with clean internal module boundaries gets you most of the benefit with none of the network-call tax. Reach for services when a real organizational or scaling pressure demands it — not because it's the architecture you see in blog posts.
2. Calling Other Services with RestClient
Spring Boot 3.2 introduced RestClient: a synchronous HTTP client with a
fluent, readable API that's now the recommended replacement for the older
RestTemplate (which is in maintenance mode — not removed, but not where
new features land). If a call is synchronous and blocking is fine — which covers most
service-to-service calls in a traditional Spring MVC app — RestClient is
the default choice.
Building a typed client bean
Rather than sprinkling raw URLs through your code, define one @Bean that
configures the base URL and shared settings once, then inject it wherever it's
needed:
package com.codeverse.week09.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestClient;
@Configuration
public class NotificationClientConfig {
@Bean
RestClient notificationRestClient(
@Value("${notification-service.base-url}") String baseUrl) {
return RestClient.builder()
.baseUrl(baseUrl)
.defaultHeader("Content-Type", "application/json")
.build();
}
}
notification-service.base-url=http://localhost:8081
With the client bean available, wrap it in a small typed class that owns the actual
call — the rest of the Task service should never see a raw RestClient or
a URL string:
package com.codeverse.week09.client;
import com.codeverse.week09.dto.NotificationRequest;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
@Component
public class NotificationClient {
private final RestClient restClient;
public NotificationClient(RestClient notificationRestClient) {
this.restClient = notificationRestClient;
}
public void sendTaskCompletedNotification(Long taskId, String taskTitle, String ownerEmail) {
var request = new NotificationRequest(taskId, taskTitle, ownerEmail);
restClient.post()
.uri("/api/notifications")
.body(request)
.retrieve()
.toBodilessEntity();
}
}
.retrieve() executes the call and, by default, throws a
RestClientResponseException for any 4xx or 5xx status — you don't have
to manually check status codes for the common case. Calling it from the Task service
looks like any other method call:
public Task completeTask(Long taskId) {
Task task = taskRepository.findById(taskId)
.orElseThrow(() -> new TaskNotFoundException(taskId));
task.markCompleted();
Task saved = taskRepository.save(task);
notificationClient.sendTaskCompletedNotification(
saved.getId(), saved.getTitle(), saved.getOwnerEmail());
return saved;
}
As written, this has a problem you'll fix in Section 4: if the Notification service is slow or down, that call blocks — or throws — and can take the task update down with it, even though marking a task complete has nothing to do with whether an email gets sent.
3. WebClient for Reactive/Async Calls
WebClient is Spring's non-blocking HTTP client, built on Project
Reactor. The Task service in this course is a traditional blocking Spring MVC app —
you don't need a fully reactive stack to have a legitimate use for
WebClient. Two situations come up constantly even in blocking apps:
genuinely fire-and-forget calls where you don't want to hold a
request thread waiting on a response you're going to ignore, and calling
multiple services concurrently instead of one after another.
Basic usage
WebClient returns a Mono<T> — a publisher of at most
one value — rather than the value itself. Nothing happens until something subscribes
to it:
package com.codeverse.week09.client;
import com.codeverse.week09.dto.NotificationRequest;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
@Component
public class NotificationWebClient {
private final WebClient webClient;
public NotificationWebClient(WebClient.Builder builder,
@org.springframework.beans.factory.annotation.Value(
"${notification-service.base-url}") String baseUrl) {
this.webClient = builder.baseUrl(baseUrl).build();
}
public Mono<Void> sendTaskCompletedNotificationAsync(
Long taskId, String taskTitle, String ownerEmail) {
var request = new NotificationRequest(taskId, taskTitle, ownerEmail);
return webClient.post()
.uri("/api/notifications")
.bodyValue(request)
.retrieve()
.toBodilessEntity()
.then();
}
}
Fire-and-forget from a blocking controller method means calling
.subscribe() instead of blocking for a result — the HTTP thread returns
immediately, and the notification call runs on Reactor's own threads in the
background:
notificationWebClient
.sendTaskCompletedNotificationAsync(saved.getId(), saved.getTitle(), saved.getOwnerEmail())
.subscribe(); // don't wait for it, don't let it block completeTask()
RestClient vs. WebClient — the short version
Use RestClient when the caller genuinely needs the response before it
can continue (the common case for most CRUD-style service calls) and you're fine
with the calling thread blocking while it waits. Reach for WebClient
when you want to fire a call without waiting, or when you need to launch several
calls at once and combine their results without burning one thread per call. Week 14
goes much further with WebClient and Reactor when the course builds a
fully reactive service end to end — for now, treat it as a targeted tool for these two
cases rather than a wholesale replacement for RestClient.
4. Resilience Patterns with Resilience4j
A network call can fail in ways an in-process method call structurally cannot: the other service can be completely unreachable, it can accept the connection and then hang for thirty seconds before responding, or it can be so overloaded that every call to it times out — and if nothing protects your service from that, your Task service's threads pile up waiting on a Notification service that's never going to answer, until the Task service itself stops responding too. That failure spreading from one service to its callers is called cascading failure, and it's the central risk this section addresses.
Resilience4j provides that protection as a set of composable annotations. Add the starter:
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-spring-boot3</artifactId>
<version>2.2.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
Timeout, retry, circuit breaker
A timeout stops your service from waiting forever on a slow response. A retry handles transient blips by trying again a bounded number of times. A circuit breaker handles the case a timeout alone can't: if a dependency is failing consistently, it stops sending it calls for a while instead of letting every single request pay the full timeout cost while waiting to fail — it "opens the circuit," fails fast locally, and periodically tests whether the dependency has recovered before closing again. Timeouts limit how long one call can hurt you; circuit breakers limit how many calls get to hurt you at all.
resilience4j.timelimiter.instances.notificationService.timeout-duration=2s
resilience4j.retry.instances.notificationService.max-attempts=3
resilience4j.retry.instances.notificationService.wait-duration=200ms
resilience4j.circuitbreaker.instances.notificationService.sliding-window-size=10
resilience4j.circuitbreaker.instances.notificationService.failure-rate-threshold=50
resilience4j.circuitbreaker.instances.notificationService.wait-duration-in-open-state=15s
resilience4j.circuitbreaker.instances.notificationService.permitted-number-of-calls-in-half-open-state=3
That configuration says: give a call up to 2 seconds, retry a failed call up to 3 times with a 200ms pause between attempts, and if 50% or more of the last 10 calls failed, open the circuit for 15 seconds before cautiously testing it again with 3 trial calls.
Applying it to the Notification call
@CircuitBreaker and @Retry both take a
fallbackMethod — called with the original arguments plus the exception
when the call ultimately fails, so you decide what "notifications are down" should
mean for the caller instead of letting the exception propagate and fail the task
update:
package com.codeverse.week09.client;
import com.codeverse.week09.dto.NotificationRequest;
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
import io.github.resilience4j.retry.annotation.Retry;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
@Component
public class NotificationClient {
private static final Logger log = LoggerFactory.getLogger(NotificationClient.class);
private final RestClient restClient;
public NotificationClient(RestClient notificationRestClient) {
this.restClient = notificationRestClient;
}
@CircuitBreaker(name = "notificationService", fallbackMethod = "notificationFallback")
@Retry(name = "notificationService")
public void sendTaskCompletedNotification(Long taskId, String taskTitle, String ownerEmail) {
var request = new NotificationRequest(taskId, taskTitle, ownerEmail);
restClient.post()
.uri("/api/notifications")
.body(request)
.retrieve()
.toBodilessEntity();
}
// Fallback signature must match the original method, plus the Throwable.
private void notificationFallback(Long taskId, String taskTitle, String ownerEmail, Throwable ex) {
log.warn("Notification service unavailable, skipping notification for task {}: {}",
taskId, ex.getMessage());
// Deliberately swallow the failure -- completing a task must not depend
// on whether the notification service happens to be healthy right now.
}
}
With this in place, if the Notification service is down: the first call fails fast
after the configured timeout, @Retry tries a couple more times, and once
the circuit breaker sees enough failures it opens and every subsequent call fails
immediately without even attempting the network round trip — while
completeTask() keeps working the whole time, because the fallback logs
and moves on instead of throwing.
Resilience behavior is exactly the kind of thing that looks fine in a demo and breaks in production. Stand up a Testcontainers-style integration test (or point the client at a deliberately unresponsive stub server) and assert that completeTask() still returns successfully — and the fallback fires — when the Notification service is unreachable, the same way you'd assert any other behavior in Week 8's test suite.
Notice how much of this section exists purely to survive the Notification service being unavailable. An event-driven design — the Task service publishes a "task completed" event and doesn't care who's listening — sidesteps circuit breakers and fallbacks entirely for this particular use case, because the caller never blocks on the callee in the first place. Week 10 covers exactly that trade-off.
5. Hands-on Exercise
Split off a Notification service and call it resiliently
Stand up a second, genuinely separate Spring Boot application, wire the Task service to it, and make sure that connection can't take the Task service down with it.
Requirements:
- Generate a second Spring Boot project (separate from your Task service) called
notification-service, packagecom.codeverse.week09.notification, running on its own port (e.g.8081). - Give it one endpoint,
POST /api/notifications, accepting a JSON body withtaskId,taskTitleandownerEmail; it can just log the notification and return202 Accepted— no real email sending required. - In the Task service, add a
NotificationClientusingRestClientthat calls the Notification service synchronously whenever a task transitions to completed. - Add Resilience4j's
@CircuitBreakerand@Retryto that call with a fallback method that logs a warning and returns normally — stop the Notification service and confirm marking a task complete still succeeds. - Add a second,
WebClient-based method that sends the same notification non-blockingly with.subscribe(), and leave a short code comment explaining when you'd choose it over theRestClientversion.
To prove the circuit breaker is actually doing something, stop the Notification service entirely and hit the Task service's complete-task endpoint several times in a row. The first couple of calls should be slow (retries plus timeouts running their course); the calls after that should fail fast and immediately, once the circuit has opened.
6. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
Beyond "it's more scalable," what does splitting a monolith into services actually trade away?
Beyond "it's more scalable," what does splitting a monolith into services actually trade away?
Independent scaling and deployability are real gains, but they come with three concrete costs: every call that used to be an in-process method call becomes a network call that can time out, partially fail, or hit a service mid-deploy in ways a method call structurally cannot; data that used to live in one transactional database now lives in two-plus databases, so you give up easy consistency and have to design deliberately around it; and you take on real operational overhead — more services to deploy, version, monitor and debug, plus service discovery and inter-service auth. None of that is a reason to never split a monolith, but "more scalable" alone isn't a strong enough justification on its own — you want a specific scaling, ownership, or deployment pressure that the split solves.
Q2
Why does a circuit breaker prevent cascading failure better than a plain timeout alone?
Why does a circuit breaker prevent cascading failure better than a plain timeout alone?
A timeout only limits how long any single call can block — it doesn't stop your service from sending call after call to a dependency that's failing consistently, and every one of those calls still pays the full timeout cost before failing, which can exhaust your thread pool or connection pool just as effectively as if there were no timeout at all. A circuit breaker tracks the failure rate across recent calls and, once it crosses a threshold, "opens" and fails new calls immediately without attempting the network round trip at all, for a cooldown period before cautiously testing recovery. In other words, a timeout bounds how much one call can hurt you; a circuit breaker bounds how many calls get to hurt you in the first place — and it's that second property that stops one struggling dependency from dragging its callers down with it.
Q3
When should you reach for WebClient instead of RestClient?
When should you reach for WebClient instead of RestClient?
RestClient is the right default for most service calls in a blocking Spring MVC app, because the calling thread genuinely needs the response before it can continue and blocking is an acceptable cost. WebClient earns its place in two more specific situations: fire-and-forget calls, where you want to kick off a request without holding a thread hostage waiting for a response you don't need synchronously (like the notification send in this lesson), and concurrent calls to multiple services, where you want to launch several requests at once and combine their results without burning one blocked thread per call. It's not a wholesale replacement for RestClient in a non-reactive app — reach for it when non-blocking behavior itself is the point, not by default.
Q4
What should a good fallback method do — and what should it not do?
What should a good fallback method do — and what should it not do?
A good fallback degrades gracefully in a way that matches what the failed call actually meant to the caller: for the Notification call, that means logging the failure so it's observable and letting completeTask() return successfully anyway, because completing a task was never supposed to depend on whether an unrelated notification could be sent. What it shouldn't do is silently pretend the failure never happened with no logging or metric at all — that turns a real outage into something nobody notices until a customer asks where their email went, which is exactly the kind of cross-service failure Week 12's observability tooling is built to surface. It also shouldn't do something riskier than the original call, like retrying in a tight loop or writing partial state, and it should only swallow failures that are genuinely safe to ignore for the caller's purpose — not every failure deserves a "log and continue" fallback.