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

  1. What a microservice architecture is
  2. Monolith, modular monolith and microservices
  3. Conway's law and the bounded context
  4. Decomposing CicloUrbana
  5. A database per service: what breaks
  6. Eventual consistency, sagas and the outbox pattern
  7. The Spring Cloud ecosystem
  8. Service discovery and API Gateway
  9. Centralised configuration
  10. Authentication between services
  11. Synchronous versus asynchronous communication
  12. Distributed observability
  13. Progressive migration from the monolith
  14. Common Mistakes and Tips
  15. Exercises

  1. 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.

  1. 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".

  1. 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.

  1. 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.

  1. 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.

  1. 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.

  1. 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 Service already gives a stable DNS name with load balancing. Adding a service registry of your own duplicates the mechanism.
  • Config Server is usually superfluous: ConfigMaps and Secrets mounted as files, read with spring.config.import: configtree: (07-02), do the job without another service to maintain.
  • The gateway may be superfluous: an Ingress routes 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.

  1. 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.

  1. 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:8888

At 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.

  1. 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.

  1. 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-service to stations-service. You cannot carry on without the answer.
  • Finishing a rental does not need to wait for the charge → an asynchronous RentalFinished event that billing-service consumes 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.

  1. 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.

  1. 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:

  1. Put a gateway in front of the monolith, which for now routes everything to it.
  2. Create billing-service with its own database, duplicating the charging functionality.
  3. Send it a copy of the events and compare results without using them yet.
  4. Once they match for weeks, move the billing traffic in the gateway to the new service.
  5. 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-bikes and ms-docks. All six share the current PostgreSQL database so as not to duplicate information and to be able to keep using the JOINs. 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

Module 2: Spring Boot Core Concepts

Module 3: Building RESTful Web Services

Module 4: Data Access with Spring Boot

Module 5: Security in Spring Boot

Module 6: Testing in Spring Boot

Module 7: Advanced Spring Boot Features

Module 8: Deploying Spring Boot Applications

Module 9: Performance and Monitoring

Module 10: Best Practices and Tips

© Copyright 2026. All rights reserved