Everything we have built so far is a single application: a monolith that is tested, observable, configurable and containerised. It is a perfectly respectable architecture and, for Ribalta's municipal bike network, probably the correct one. But sooner or later somebody asks the question: what if billing had its own lifecycle? What if the stations team deployed without coordinating with the rentals team? What if we had to scale only the availability query, which gets a hundred times more traffic than the rest?
This lesson answers those questions honestly. We will look at what problem microservices really solve and what their price is, how you decide where to cut, what breaks when each service has its own database — the JOINs and the ACID transactions from 04-07 cease to exist — and what patterns replace them. We will walk through the Spring Cloud ecosystem pointing out where it is superfluous, build a minimal gateway for CicloUrbana and finish with a guide to progressive migration and an honest checklist. A warning from the very first line: the right answer for a project like CicloUrbana is usually the modular monolith, and this lesson takes explaining why seriously.
Contents
- What a microservice architecture is
- Monolith, modular monolith and microservices
- Conway's law and the bounded context
- Decomposing CicloUrbana
- A database per service: what breaks
- Eventual consistency, sagas and the outbox pattern
- The Spring Cloud ecosystem
- Service discovery and API Gateway
- Centralised configuration
- Authentication between services
- Synchronous versus asynchronous communication
- Distributed observability
- Progressive migration from the monolith
- Common Mistakes and Tips
- Exercises
- What a microservice architecture is
A microservice system is a set of small services, independently deployable, each one the owner of its data and communicating over the network. The important part of the definition is in those two words: independently deployable. If publishing a change in billing requires coordinating the deployment with rentals, there are no microservices: there is a distributed monolith, which combines the drawbacks of both worlds.
The problems it solves are organisational and operational before they are technical:
- Team autonomy. Five teams deploying five times a day without treading on each other.
- Selective scaling. Twenty replicas of the station query service and two of billing.
- Failure isolation. The payment gateway goes down and bikes keep being rented.
- Technology freedom. A recommendation service in Python alongside services in Java 21.
- Different lifecycles. A fare engine that changes every week versus a station catalogue that changes every quarter.
And the price, which is rarely stated with the same clarity: complexity moves from the code to operations. A method call becomes a network call that can be slow, fail or arrive twice. A transaction becomes coordination between services. A NullPointerException becomes an investigation across the logs of four processes. None of that is paid once: it is paid every single day.
- Monolith, modular monolith and microservices
| Criterion | Monolith | Modular monolith | Microservices |
|---|---|---|---|
| Deployment units | 1 | 1 | N |
| Internal boundaries | Blurred | Explicit and verified | Physical (separate processes) |
| Database | One shared | One, with a schema per module | One per service |
| Transactions | ACID (04-07) | ACID | Eventual consistency, sagas |
| Cross-area queries | JOIN |
JOIN or an internal API |
Network calls or replicas |
| Scaling | All or nothing | All or nothing | Selective, per service |
| Internal latency | Nanoseconds | Nanoseconds | Milliseconds, and variable |
| Team autonomy | Low | Medium | High |
| Operational cost | Low | Low | Very high |
| Debugging a failure | One call stack | One call stack | Distributed traces (09-06) |
| Prerequisites | None | Discipline | CI/CD, observability, automation |
| Cost of getting a boundary wrong | Refactoring | Refactoring | Redesign and data migration |
That last row is the reason for the warning at the start. In a modular monolith, moving a class from one module to another is an afternoon's work; between microservices, it is a data migration, a broken contract, two coordinated deployments and a period of coexistence. And boundaries are almost never right first time, because you only learn where they are once you know the domain well.
Hence the recommendation the industry has held for years: start with a well-modularised monolith and extract services when a concrete pain justifies it. A concrete pain is "the billing team cannot deploy without us" or "I need to scale the station query to twenty replicas without scaling the rest", not "microservices are more modern".
- Conway's law and the bounded context
"Organisations which design systems are constrained to produce designs which are copies of the communication structures of these organisations." — Melvin Conway, 1967
The practical consequence is brutal: if the Ribalta council has a single team, the microservices it designs will end up coupled, because there is no organisational boundary keeping them apart. And the reverse: if there are four teams with clear responsibilities, the architecture will tend to align with them even if something else is designed. Designing the architecture is designing the organisation.
To decide where to cut, the criterion that works comes from domain-driven design (DDD): the bounded context, a boundary within which a business term has one single meaning. The practical test is to ask about the words:
- For rentals, a "bike" is an object with a status, a battery and a current position.
- For billing, a "bike" is barely a reference on an invoice line.
- For maintenance, a "bike" is an asset with a repair history and a warranty.
Three different models of the same concept, each correct in its own context. When the same term means different things depending on who uses it, there is a boundary there. And the reverse: if two supposed services constantly need the same complete model, they were never two.
Where the boundary is NOT, and these are the two most frequent mistakes:
| Wrong cut | Why it fails |
|---|---|
| By technical layer (controllers-service, repositories-service) | Any functional change touches all three services: maximum coupling at network cost |
| By table (stations-service, bikes-service, docks-service) | It fragments a single business concept; every operation needs three calls |
| By CRUD entity | It produces anaemic services that only serve data, with the logic scattered everywhere |
A microservice must be able to answer a complete business request on its own most of the time. If serving its most common use case requires calling three others, the boundary is in the wrong place.
- Decomposing CicloUrbana
Applying the criterion above, the Ribalta network admits four reasonable candidates:
graph TB
GW[API Gateway<br/>/api/v1/**]
GW --> EST[stations-service<br/>stations, docks,<br/>occupancy, bikes]
GW --> ALQ[rentals-service<br/>rental, return,<br/>fares, incidents]
GW --> USU[users-service<br/>citizens, roles,<br/>authentication]
FAC[billing-service<br/>charges, receipts,<br/>payment gateway]
ALQ -.->|RentalFinished event| FAC
ALQ -->|availability query| EST
EST --> BDE[(stations DB)]
ALQ --> BDA[(rentals DB)]
USU --> BDU[(users DB)]
FAC --> BDF[(billing DB)]
| Service | Why it is a context of its own | Lifecycle and scale |
|---|---|---|
| stations-service | A model of physical infrastructure; it changes little | Read-heavy: the mobile app queries availability constantly |
| rentals-service | The core of the business: start, finish, price | Write-heavy, peaks at rush hour |
| users-service | Identity and authentication, with its own regulation (GDPR) | Stable, little traffic |
| billing-service | Accounting vocabulary, integrates with the external gateway | Asynchronous, tolerates delays |
Note that bikes live inside stations-service rather than in a service of their own: their lifecycle is tied to that of the infrastructure, and separating them would force a network call on every availability query, which is precisely the most frequent operation in the whole system. It is a direct application of the rule from the previous section.
And an uncomfortable observation: of the four, only billing-service has a strong justification. It is the one that integrates with a third party, the one that tolerates eventual consistency by nature, the one with the most distinct vocabulary and the one that needs the fewest cross-queries. If CicloUrbana had to extract a single service, that would be the candidate, and the other three could remain modules of the monolith for years.
- A database per service: what breaks
The non-negotiable rule of microservices is that each service is the exclusive owner of its data: nobody else reads it directly. Sharing a database between services couples them at the worst possible point — the schema — and destroys deployment independence: a Flyway migration (04-08) would then require everyone to coordinate.
And that rule breaks two things we have been using all course:
JOINs disappear. This perfectly natural query from the monolith:
SELECT r.id, u.name, s.name AS station, r.total_amount
FROM rentals r
JOIN users u ON u.id = r.user_id
JOIN stations s ON s.id = r.origin_station_id
WHERE r.started_at >= :from;is no longer possible: users and stations are in other databases. The alternatives, with their price:
| Alternative | How it works | Cost |
|---|---|---|
| Composition in the client | The service asks the other two for the names | N+1 over the network (04-04, now with latency) |
| Composition in the gateway | The gateway aggregates the responses | Business logic in the infrastructure |
| Local read-only replica | Each service keeps a copy of the foreign data it needs | Eventual consistency, deliberate duplication |
| CQRS with a materialised view | A query service builds views from the events | High complexity, very efficient for reads |
The third is the most used: rentals-service stores the stationName alongside the rental, updating it when a StationRenamed event arrives. It is deliberate duplication, something that in a normalised database would be a mistake and here is the right solution.
ACID transactions disappear. The whole of module 4 relied on @Transactional guaranteeing atomicity: if the charge failed, the rental was rolled back. With two databases, that no longer exists. Two-phase distributed transactions (XA) technically exist, but in practice they are ruled out: they lock resources across several systems, they do not scale and an external payment gateway does not take part in them.
- Eventual consistency, sagas and the outbox pattern
What replaces the distributed transaction is eventual consistency: the system passes through inconsistent intermediate states and converges. Applied to Ribalta: for a few seconds, a rental is finished and not yet charged. That is not a technical defect, it is a business decision that has to be taken consciously: is it acceptable for a citizen to see their rental closed before the charge appears? Nearly always yes. Is it acceptable for a bike to show as available when it no longer is? Nearly never.
A saga is a sequence of local transactions where each step publishes an event that triggers the next, and each one has its compensation — there is no rollback, there is an action that undoes.
sequenceDiagram
participant C as Citizen
participant A as rentals-service
participant B as Event bus
participant F as billing-service
participant P as Payment gateway
C->>A: POST /rentals/42/finish
A->>A: closes rental (local transaction) + outbox
A-->>C: 200 OK — rental finished
A->>B: RentalFinished(42, €4.80)
B->>F: delivers the event
F->>P: charges €4.80
alt charge succeeds
F->>B: PaymentCompleted(42)
B->>A: marks the rental as charged
else charge declined
F->>B: PaymentDeclined(42, insufficient funds)
B->>A: compensation: flags the debt and blocks new rentals
end
Choreographed versus orchestrated:
| Choreographed | Orchestrated | |
|---|---|---|
| How it advances | Each service reacts to events | A coordinator tells each one what to do |
| Coupling | Low | Medium: everyone depends on the coordinator |
| Visibility of the flow | None: it is not written down anywhere | Explicit, in one single place |
| Debugging | Hard | Easier |
| When to use it | 2-3 simple steps | Long flows, with many compensations |
The choreographed one is the one in the diagram and the one suited to CicloUrbana's flow. Its weak point is serious: the complete flow is not written in any file; to know what happens when a rental is finished you have to read the listeners of four services. As soon as the saga goes beyond three or four steps, an explicit orchestrator makes up for its coupling.
The outbox pattern solves a subtle but fatal problem. This code is wrong:
@Transactional
public void finish(Long rentalId) {
rental.finish(); // writes to PostgreSQL
broker.publish(new RentalFinished(...)); // writes to Kafka ← problem
}They are two different systems with no common transaction. If PostgreSQL's commit fails after publishing, there is an event for a rental that was never closed; if the broker fails after the commit, the rental is closed and nobody will ever charge for it. The solution is to write the event in the same transaction and in the same database, into an outbox table, and publish it afterwards:
@Transactional
public void finish(Long rentalId) {
Rental rental = repository.findById(rentalId).orElseThrow();
rental.finish(Instant.now(clock));
outboxRepository.save(new OutboxMessage(
"RentalFinished", rentalId, json.write(event))); // same transaction
}A separate process — a @Scheduled task with ShedLock from 07-03, or a change data capture connector such as Debezium — reads the table and publishes. Since it may publish twice if it fails right after sending, the consumer must be idempotent: that is what turns an "at least once" delivery into an "exactly once" effect.
- The Spring Cloud ecosystem
| Piece | What it solves | Alternative on Kubernetes (08-04) |
|---|---|---|
| Config Server | Centralised, versioned configuration | ConfigMap and Secret |
| Eureka / Consul | Discovery: where each instance is | Service + internal DNS |
| Spring Cloud Gateway | Entry point, routing, filters | Ingress or a service mesh |
| OpenFeign | Declarative HTTP client | RestClient or @HttpExchange (07-06) |
| Circuit Breaker | An abstraction over Resilience4j | The same library, without the abstraction |
| Micrometer Tracing (formerly Sleuth) | Distributed traces | The same, plus a service mesh |
| Stream / Bus | An abstraction over Kafka or RabbitMQ | The broker's native client |
And here comes the part that is rarely said. A good deal of Spring Cloud was born before Kubernetes was the standard, to solve inside the application problems that the platform now solves. If CicloUrbana is deployed on Kubernetes:
- Eureka is superfluous: a Kubernetes
Servicealready gives a stable DNS name with load balancing. Adding a service registry of your own duplicates the mechanism. - Config Server is usually superfluous:
ConfigMaps andSecrets mounted as files, read withspring.config.import: configtree:(07-02), do the job without another service to maintain. - The gateway may be superfluous: an
Ingressroutes by path and by host. It still makes sense when logic is needed — response aggregation, transformation, rate limiting per authenticated user.
What is never superfluous: resilience (07-06), distributed observability (09-06) and messaging. The rule: do not add a Spring Cloud piece without being able to name the concrete problem it solves and checking that your platform does not already solve it. Every component is another service to deploy, monitor, update and that can fall over.
- Service discovery and API Gateway
Discovery answers "what address is rentals-service at right now?", a question with no fixed answer because instances come, go and change IP. A registry — Eureka, Consul or Kubernetes DNS — maintains that list, and the client asks for "a healthy instance of rentals-service" instead of for an IP.
The gateway is the single entry point from outside. It concentrates whatever makes no sense repeating in every service: routing, TLS termination, CORS, rate limiting and a first validation of the token.
spring:
cloud:
gateway:
routes:
- id: stations
uri: lb://stations-service # lb: resolved through discovery
predicates:
- Path=/api/v1/stations/**
filters:
- name: CircuitBreaker
args: { name: cbStations, fallbackUri: forward:/fallback/stations }
- id: rentals
uri: lb://rentals-service
predicates:
- Path=/api/v1/rentals/**
filters:
- name: RequestRateLimiter
args:
redis-rate-limiter.replenishRate: 20
redis-rate-limiter.burstCapacity: 40
default-filters:
- AddRequestHeader=X-Trace-Id, ${traceId} # consistent with TraceFilter (03-06)Three details. lb:// delegates resolution to discovery and spreads the load across healthy instances. The predicates decide which requests enter each route — by path, header, method, host or even time of day. And Spring Cloud Gateway is reactive, built on WebFlux: it cannot be mixed with spring-boot-starter-web in the same project, and its filter code must not block.
What the gateway must not do: business logic. A gateway that decides whether a citizen may rent becomes a monolith in disguise that the whole team has to go through, and a single point of failure with coordinated deployments. It routes, protects and observes; it does not decide.
- Centralised configuration
With twenty services and four environments, configuration scatters. Spring Cloud Config Server centralises it in a Git repository and serves it over HTTP:
# Config Server
spring:
cloud:
config:
server:
git:
uri: https://github.com/ribalta-council/ciclourbana-config
search-paths: '{application}'# Each client service
spring:
application:
name: rentals-service
config:
import: optional:configserver:http://config:8888At startup the client asks for rentals-service with its active profiles (07-02) and receives the merged configuration, versioned in Git with history and code reviews. With Spring Cloud Bus, a change can also be refreshed on the fly without restarting.
Real advantages: a single source of truth, a complete history of who changed what and when, and secret encryption with {cipher}. Equally real drawbacks: another service to maintain which, if it falls over, stops all the others starting; added latency at startup; and the temptation to put things there that should be code. As section 7 said: on Kubernetes, ConfigMaps are usually enough.
- Authentication between services
The JWT from 05-04 is still the central piece, but the division of responsibilities changes:
graph LR
C[Citizen] -->|JWT| GW[Gateway<br/>validates signature and expiry]
GW -->|forwards the JWT| ALQ[rentals-service<br/>validates again]
ALQ -->|forwards the JWT| EST[stations-service<br/>validates again]
Each service validates the token on its own, and this is not pointless redundancy: it is the principle of zero trust. If stations-service trusted that the gateway had already validated, anybody who reached that service from inside the network — another compromised service, an attacker already in — would have full access. Validation is cheap: checking an HS256 signature takes microseconds and requires calling nobody.
The pattern is called token relay: the outgoing request carries the same Authorization that came in, and each service acts as a resource server (spring-boot-starter-oauth2-resource-server in an OIDC scenario, or the custom JwtAuthenticationFilter from 05-04). Forwarding the header is implemented with an HTTP client interceptor, which is exactly what we will build in 07-06.
For calls with no user — a scheduled billing task that queries rentals — there is no token to forward. There you use the client credentials flow: the service obtains its own token with its own identity, not with any citizen's. And for mutual authentication between services, mTLS, which the service mesh usually provides without touching the code.
- Synchronous versus asynchronous communication
| Synchronous (REST, gRPC) | Asynchronous (RabbitMQ, Kafka) | |
|---|---|---|
| Who waits | The caller, blocked | Nobody |
| Temporal coupling | Both must be alive at the same time | The receiver may be down |
| Failure propagation | High: one outage cascades | Low: messages wait in the queue |
| Perceived latency | The sum of every call | Immediate response |
| Consistency | Immediate | Eventual |
| Debugging | Simple: there is a stack | Hard: you have to follow messages |
| When to use it | I need the answer to carry on | I only need to announce that something happened |
The last row is the criterion, and it is more useful than any other consideration. Applied to CicloUrbana:
- Starting a rental needs to know whether the bike is available → a synchronous call from
rentals-servicetostations-service. You cannot carry on without the answer. - Finishing a rental does not need to wait for the charge → an asynchronous
RentalFinishedevent thatbilling-serviceconsumes when it can. If billing is down for an hour, citizens keep renting and returning bikes, and the charges are processed when it comes back.
That second case is the best illustration of the value of messaging: it turns a service outage into a delay rather than into a breakdown. The price is eventual consistency and the obligation for the consumer to be idempotent, because brokers guarantee "at least once": if RentalFinished(42) arrives twice, it must not be charged twice. The usual form is a table of already-processed identifiers, queried inside the consumer's own transaction.
- Distributed observability
In the monolith, a failure leaves a complete call stack. With four services, "finishing a rental takes eight seconds" is a question with no answer until you know in which of the four those seconds go. That is why distributed observability is not an extra: it is a prerequisite.
The three pieces, seen already or still to come:
| Piece | What it provides | Where it is covered |
|---|---|---|
| Traces | A traceId that crosses every service and measures each hop |
09-06 |
| Metrics | Latency, error rate and saturation per service | 09-03 and 09-04 |
| Correlated logs | Every line of one operation, across every service | 09-05 |
Micrometer Tracing propagates the context through the W3C standard's traceparent headers, and integrates the identifier into the MDC, exactly as the TraceFilter from 03-06 did within a single process. And /actuator/health from 07-01 takes on a new role: with four services, the system's aggregate status is the union of four probes, and the orchestrator withdraws instances on its own.
The decision criterion that follows: if the team does not have distributed traces and centralised logs today, it is not ready for microservices. It is not a matter of abstract maturity: without those tools, the first incident in production is unsolvable.
- Progressive migration from the monolith
Nobody rewrites a running monolith. The path that works has three stages, and the first two deliver value even if the third is never reached.
Stage 1: modularise on the inside. Reorganise the code into modules with explicit boundaries, which is what we already started in 01-04 with the .stations, .rentals, .users, .security and .common packages. The difference is that now the boundaries are verified: nobody accesses another module's internal classes, communication goes through its public API or through events, and each module has its own schema in the database.
Spring Modulith turns that discipline into something checkable: it defines what a module is, allows only the public parts to be exposed, and offers a test that fails the build if a module imports another's internals. With it, the architecture stops depending on vigilance in code reviews:
class ModularityTest {
@Test
void modulesRespectTheirBoundaries() {
ApplicationModules.of(CicloUrbanaApplication.class).verify();
}
}Stage 2: extract the first service with the strangler fig pattern. The name comes from the strangler fig, which grows around a tree until it replaces it. Applied to CicloUrbana, to extract billing:
- Put a gateway in front of the monolith, which for now routes everything to it.
- Create
billing-servicewith its own database, duplicating the charging functionality. - Send it a copy of the events and compare results without using them yet.
- Once they match for weeks, move the billing traffic in the gateway to the new service.
- Delete the billing code from the monolith.
The key is in steps 3 and 4: there is a period of running in parallel in which you can go back by changing one route. Without that net, extraction is a jump without a parachute.
Stage 3: repeat only when it hurts. Every later extraction must be justified by a concrete, measurable pain.
The honest checklist. If you cannot answer "yes" to most of these, the answer is the modular monolith:
- [ ] Is there more than one team getting in each other's way when deploying?
- [ ] Is continuous delivery automated, from commit to production, and does it take minutes?
- [ ] Are distributed traces and centralised logs working today?
- [ ] Is the infrastructure created automatically, with no manual steps?
- [ ] Can the team operate four services, with on-call rotas and alerts?
- [ ] Are the domain boundaries known well enough to get them right?
- [ ] Is there a concrete pain that the modular monolith cannot solve?
- [ ] Is eventual consistency acceptable in the affected operations?
For CicloUrbana, with a small team and a stable domain, the honest answer is not yet, and that is a legitimate conclusion of this lesson.
Common Mistakes and Tips
Starting with microservices without knowing the domain. Boundaries are got right once the domain is known, and that comes after building it.
Sharing the database between services. That is the distributed monolith: the coupling of a monolith with network latency and without its transactions.
Cutting by layers or by tables. It produces services that cannot serve a request without calling three others.
Services that only deploy together. If deployments have to be coordinated, the independence — the one real benefit — does not exist.
Publishing the event outside the transaction. Without an outbox, either there are events for things that never happened or there are things that happened with no event.
Non-idempotent consumers. Brokers deliver "at least once": a duplicate charge is only a matter of time.
Adding the whole of Spring Cloud "because that is what people use". Every piece is another service to operate; on Kubernetes, several are already solved.
Putting business logic in the gateway. It becomes a monolith in disguise and a single point of failure.
Tip: start with the modular monolith and verify its boundaries with Spring Modulith. It delivers 80 % of the benefit for 5 % of the cost.
Tip: extract the least coupled service first, the one that integrates with a third party or tolerates eventual consistency. In CicloUrbana, billing.
Tip: build the observability before the first service. The day you need it will be too late to install it.
Tip: write the contracts before the code. OpenAPI (03-07) for the synchronous parts and a versioned schema for the events. A broken contract between services is discovered in production.
Exercises
Exercise 1: deciding the boundary
The Ribalta council wants to add annual passes to CicloUrbana: a citizen pays a fee, gets a reduced fare for a year, and the pass must be validated when each rental starts. Decide whether this justifies a service of its own, a module inside rentals-service or a module of the monolith. Argue using the criteria from sections 3 and 4, and describe how it would communicate with the rest in each case.
Exercise 2: the saga of a rental with a pass
Design the complete flow, as a choreographed saga, of "finish a rental with charging and pass usage update", with three participants (rentals-service, billing-service, passes-service). Draw the diagram, define the events with their data, state the compensation for each step and explain how you guarantee that a charge is not duplicated if the event arrives twice.
Exercise 3: reviewing an architecture proposal
A consultant proposes this architecture to the council. Find the problems and propose an alternative.
"We split CicloUrbana into six microservices:
ms-controllers(the whole REST layer),ms-business(all the services),ms-data(all the repositories),ms-stations,ms-bikesandms-docks. All six share the current PostgreSQL database so as not to duplicate information and to be able to keep using theJOINs. They are deployed together from the same pipeline to guarantee that the versions are consistent. We use Eureka, Config Server, Gateway, Feign and Bus, the whole Spring Cloud ecosystem, on Kubernetes."
Solutions
Solution 1
The recommendation is a module inside rentals-service — or, if CicloUrbana is still a monolith, one more module. The reasoning, criterion by criterion:
The vocabulary test. A "pass" only has meaning in the rental context: it is a fare modifier. It does not appear with another meaning in stations or in maintenance. There are no two different models of the same term, so there is no context boundary.
The complete-request test. The most frequent operation — starting a rental — always needs to check whether the citizen has a valid pass. A separate service would turn every rental start into an extra network call on the critical path, with its latency, its chance of failure and its circuit breaker (07-06). That is exactly what section 3 describes as a badly placed boundary.
The lifecycle test. The pass rules change at the same cadence as the fares, and the same council department decides both. Nothing suggests independent deployment cycles.
The organisational test. There is no passes team. By Conway's law, a service with no team of its own ends up coupled to whoever actually maintains it.
Where a service of its own does fit is in charging the annual fee, which already belongs to billing-service: issuing the pass receipt is the same accounting vocabulary as charging for a rental, it tolerates eventual consistency and it integrates with the external gateway.
The resulting communication. Inside rentals-service, the passes module exposes an internal API (PassService.currentFare(userId, date)) invoked as a method call, without the network and inside the same transaction. Outwards it publishes two events: PassPurchased, which billing-service consumes to issue the receipt, and PassExpired, which is used to notify the citizen. If one day there were a dedicated team and the rules became far more complex, extraction would be straightforward precisely because the module already has an explicit boundary: that is stage 1 of section 13 doing its job.
Solution 2
sequenceDiagram
participant C as Citizen
participant A as rentals-service
participant B as Bus
participant AB as passes-service
participant F as billing-service
C->>A: POST /api/v1/rentals/42/finish
A->>A: closes rental + computes €4.80 + outbox (one transaction)
A-->>C: 200 OK
A->>B: RentalFinished(id=42, user=7, amount=4.80, minutes=32)
B->>AB: delivers
AB->>AB: deducts 32 min from the pass; €0.00 left to charge
AB->>B: PassUsageApplied(42, finalAmount=0.00)
B->>F: delivers
alt finalAmount = 0
F->>B: PaymentNotRequired(42)
else finalAmount > 0 and charge succeeds
F->>B: PaymentCompleted(42, 4.80)
else charge declined
F->>B: PaymentDeclined(42, "insufficient funds")
B->>AB: compensation: returns the 32 min to the pass
B->>A: compensation: flags the debt and blocks new rentals
end
The events and their data. Each one carries eventId (a unique UUID), occurredAt (an instant) and the schema version, as well as its payload: RentalFinished with rentalId, userId, grossAmount and minutes; PassUsageApplied with rentalId, minutesUsed and finalAmount; PaymentCompleted and PaymentDeclined with rentalId, amount and, in the latter, reason.
The compensations, which replace the rollback:
| Step | Compensation if a later step fails |
|---|---|
| Closing the rental | Not undone: the citizen returned the bike and that is a fact. It is flagged PAYMENT_PENDING |
| Pass usage | MinutesReturnedToPass(42, 32): the minutes are credited back |
| Charge | A successful charge is compensated with a refund, not with a deletion |
The first row holds the most important lesson about sagas: not everything can be undone. Compensation is not going back to the previous state, it is taking the system to a new consistent state, which here is "rental finished with an outstanding debt".
Idempotency, which is what makes "at least once" delivery safe. In billing-service:
@Transactional
public void onReceive(PassUsageApplied event) {
if (processedRepository.existsById(event.eventId())) {
return; // already handled: ignore it
}
processedRepository.save(new ProcessedEvent(event.eventId(), Instant.now(clock)));
if (event.finalAmount().signum() > 0) {
gateway.charge(event.rentalId(), event.finalAmount());
}
}Three details make it correct: the check and the record go in the same transaction as the effect, so it cannot end up recorded but not charged nor charged but not recorded; ProcessedEvent's primary key guarantees uniqueness in the database, so two concurrent consumers do not both get through; and the table is purged periodically with a scheduled task from 07-03, because it cannot grow indefinitely. As an extra safeguard, the call to the gateway carries its own idempotency key (the rentalId), so that even if CicloUrbana asked for two charges, the gateway would only apply one.
Solution 3
The proposal accumulates practically every mistake in the lesson. The problems, ordered by severity:
| # | Problem | Why it is serious |
|---|---|---|
| 1 | Cutting by layers (ms-controllers, ms-business, ms-data) |
Any functional change — adding a field to a station — touches all three services and requires three coordinated deployments. It is maximum coupling while paying network latency |
| 2 | Shared database | It destroys independence: one Flyway migration affects all six. It is the definition of a distributed monolith |
| 3 | Joint deployment | The one real benefit of microservices was deploying separately; if they deploy together, the whole cost has been paid with no advantage at all |
| 4 | Cutting by tables (ms-stations, ms-bikes, ms-docks) |
It fragments a single business concept; querying the availability of "Main Square" would require three network calls |
| 5 | Overlapping responsibilities | ms-business and ms-stations tread on each other: it is unclear where the station logic lives |
| 6 | The whole of Spring Cloud on Kubernetes | Eureka duplicates the Services, Config Server duplicates the ConfigMaps: two more services to operate and to fall over, solving nothing new |
| 7 | Observability is not mentioned | Without distributed traces, the first incident across six services is unsolvable |
| 8 | There is no justification | No concrete pain that the monolith cannot solve is named |
The alternative. Stage 1: leave CicloUrbana as a single deployment, reorganised into modules verified with Spring Modulith — stations (with bikes and docks inside, because they are one single concept), rentals, users, billing — each with its own schema in PostgreSQL and communicating through a public API or Spring events. That gives real boundaries, limits checked by the build and zero operational cost.
Stage 2: build the observability — traces, metrics and centralised logs (09-03 to 09-06) — and continuous delivery (08-05). They are a prerequisite, not a consequence.
Stage 3: if and only if a concrete pain appears, extract billing-service with the strangler fig pattern, with its own database, communicating through events with an outbox and idempotent consumption. One service, not six.
From Spring Cloud, on Kubernetes, only what the platform does not provide would remain: Resilience4j for resilience (07-06), Micrometer Tracing for the traces (09-06) and, if messaging justifies it, the broker's client. No Eureka, no Config Server, no Bus.
The summary you would give back to the consultant: the proposal has the operational cost of six microservices, the coupling of a monolith and none of the benefits of either architecture.
Conclusion
This lesson has been as much about when not to use microservices as about how to use them. You know that their operational definition is independently deployable, and that if two services must be deployed together they are not microservices but a distributed monolith. You have the table comparing monolith, modular monolith and microservices across eleven dimensions, with the decisive row: getting a boundary wrong costs an afternoon in a module and a data migration between services. You know Conway's law and its consequences, and the criterion that does work for deciding where to cut — the bounded context, detected through the vocabulary test — along with the two cuts that never work: by technical layer and by table.
You have decomposed CicloUrbana into stations-service, rentals-service, users-service and billing-service, understanding why bikes stay inside stations and why, of the four, only billing has a strong justification. You know what breaks with a database per service — the JOINs and the ACID transactions from 04-07 — and what replaces them: composition, local read-only replicas as deliberate duplication, eventual consistency, choreographed and orchestrated sagas with their compensations, and the outbox pattern that avoids the subtle failure of writing to the database and to the broker without a common transaction, with consumer idempotency as the piece that turns "at least once" into "exactly once".
You know the Spring Cloud ecosystem piece by piece and, more importantly, when it is superfluous: on Kubernetes, Eureka duplicates the Services and Config Server duplicates the ConfigMaps. You know what discovery and the gateway provide, you have seen the minimal Spring Cloud Gateway configuration routing /api/v1/stations/** and /api/v1/rentals/**, and you are clear that the gateway routes and protects but does not decide. You understand token relay and why each service validates the JWT on its own, the criterion for choosing between synchronous and asynchronous communication — do I need the answer to carry on? — and why distributed observability is not an extra but a prerequisite. And you have the migration guide: modularise first with Spring Modulith verifying the boundaries in the build, then extract the first service with the strangler fig pattern and its parallel-running period, and the checklist that for CicloUrbana gives a perfectly legitimate "not yet" today.
The most practical part of all is still outstanding. We have said that rentals-service calls stations-service, that the JWT and the trace identifier have to be forwarded, that a circuit breaker is needed in the gateway and that one service's outage must not cascade. None of that have we written yet, and it is still needed even if CicloUrbana is never split up: as soon as the application calls the Ribalta payment gateway — an external system that may be slow, down or returning errors — all those problems appear in exactly the same way. The next lesson, Service Communication and Fault Tolerance, solves them with code: RestClient and declarative interfaces, timeouts that cannot be forgotten, interceptors that propagate the trace and the token, and Resilience4j with its retries, circuit breakers, rate limiters, bulkheads and graceful degradation, all tested with a mock server pretending to be slow, down and broken.
Spring Boot Course
Module 1: Introduction to Spring Boot
- What Is Spring Boot?
- Setting Up Your Development Environment
- Building Your First Spring Boot Application
- Understanding the Project Structure
- Application Startup and Lifecycle
Module 2: Spring Boot Core Concepts
- Spring Boot Annotations
- Dependency Injection in Spring Boot
- Bean Scope and Lifecycle
- Spring Boot Configuration
- Spring Boot Properties
- Auto-Configuration and Starters from the Inside
Module 3: Building RESTful Web Services
- Introduction to RESTful Web Services
- Creating REST Controllers
- Handling HTTP Methods
- Validating Input Data
- DTOs and Mapping Between Layers
- Exception Handling in REST
- Documenting the API with OpenAPI
Module 4: Data Access with Spring Boot
- Introduction to Spring Data JPA
- Configuring Data Sources
- Creating JPA Entities
- Relationships Between Entities
- Using Spring Data Repositories
- Query Methods in Spring Data JPA
- Transactions and Persistence Management
- Schema Migrations with Flyway
Module 5: Security in Spring Boot
- Introduction to Spring Security
- Configuring Spring Security
- User Authentication and Authorization
- Implementing JWT Authentication
- Method-Level Security and API Hardening
Module 6: Testing in Spring Boot
- Introduction to Testing
- Unit Testing with JUnit
- Mocking with Mockito
- Integration Testing
- Testing with Testcontainers
Module 7: Advanced Spring Boot Features
- Spring Boot Actuator
- Spring Boot Profiles
- Scheduled Tasks and Asynchronous Execution
- Spring Boot with Docker
- Spring Boot and Microservices
- Service Communication and Fault Tolerance
Module 8: Deploying Spring Boot Applications
- Introduction to Deployment
- Deploying to Heroku
- Deploying to AWS
- Deploying to Kubernetes
- Continuous Integration and Delivery
Module 9: Performance and Monitoring
- Performance Tuning
- Caching with Spring Cache
- Monitoring with Spring Boot Actuator
- Using Prometheus and Grafana
- Logging and Log Management
- Distributed Tracing
