Spring Boot Microservices Project: What to Build, What to Skip
Most Spring Boot microservices projects are three CRUD apps in Docker with a Eureka server; here is what actually makes it a distributed-systems project, and what to cut.
Search "spring boot microservices project" and you get the same architecture forty times over: an order service, a user service, a product service, each with its own Postgres container, a Eureka server, a Spring Cloud Gateway in front, and a README diagram with a lot of arrows. Every service does CRUD. Every call between services is a synchronous REST call. Nothing ever fails.
That project takes eight weeks and answers exactly one question in the viva: can you configure Spring Boot three times? The question your examiner will actually ask is "why is this not a monolith?" If the honest answer is "because the project title says microservices", the demo is already over.
So this post covers two things. First, when microservices are the wrong choice for a capstone, which is most of the time. Second, if you are doing it anyway, what turns three CRUD apps in Docker into a real distributed-systems project: idempotent consumers, a transactional outbox, a saga whose compensation actually fires, a dead-letter topic, and one trace ID running through all of it. Plus a list of things everyone builds that earn nothing.
Should your final year project be microservices at all?
Probably not. Microservices solve an organisational problem: many teams deploying independently without stepping on each other. You are one to four people shipping once, so you have none of the problems microservices solve and all of the costs they impose. A modular monolith with clear package boundaries, one database, real transactions and a proper test suite is a better piece of engineering than five services that fall over when one of them restarts, and choosing it on purpose scores better than a distributed system you cannot explain. If the real contribution of your project is somewhere else, an ML model or a compiler or a mobile app, build the monolith, ship the contribution, and spend the six weeks you saved on evaluation.
What turns three CRUD apps in Docker into a distributed-systems project
There is one honest reason to build microservices as a capstone: the project is about distributed systems. Partial failure, message ordering, delivery semantics, eventual consistency. That is a legitimate and interesting subject, but then the project has to actually be about those things. The property that makes it real is singular: one business operation spans more than one service and more than one database, and it must end in a consistent state even when a service dies halfway through. Everything below exists to serve that sentence.
- Services communicate asynchronously over Kafka, not by calling each other's REST endpoints and blocking.
- Every consumer is idempotent, because Kafka will deliver the same record twice and your grader can force it to.
- Every event leaves a service through an outbox table, so a commit and a publish cannot disagree.
- A business operation spanning services is modelled as a saga with explicit compensation, not with @Transactional across HTTP.
- Records that can never succeed land in a dead-letter topic instead of being silently dropped, which is what Spring does by default.
- One request produces one trace across all services, so you can point at a screenshot and say where the 400ms went.
The domain does not need to be original; it needs one operation that cannot fit in a single transaction. Placing an order is the classic: debit the customer's wallet in the payment service, reserve stock in the inventory service, confirm the order in the order service. Three services, three databases, one outcome that has to be all-or-nothing. Three services is enough, and a notification service adds YAML and no argument. Notice why the tutorial version breaks: OrderController calls PaymentClient.debit() over HTTP, then InventoryClient.reserve() over HTTP. If the debit succeeds and the reserve call times out, you do not know whether stock was reserved. Retry and you may reserve twice; give up and you have taken the money for nothing. Kafka does not fix that by itself, but it gives you a durable log to recover from, and recovery is the thing you can demonstrate.
Your Kafka consumer will run twice. Here is the wrong version.
Kafka gives you at-least-once delivery, and Spring's listener container commits offsets after the whole batch from a poll has been processed (AckMode.BATCH is the default, not per record). If the process dies after your database commit and before the offset commit, every record in that batch, including the one you just handled, is delivered again on restart. If your listener throws after a partial side effect, the error handler replays the entire method. Both are easy to reproduce and both will happen to you.
The idempotent consumer, properly
The fix is not clever. Give every event a UUID when it is published, and record in the consumer's own database that this consumer has handled that UUID. Do the recording and the business write in the same local transaction. If the insert conflicts, you have seen the event before, so stop. The DDL below is Postgres. On MySQL the equivalent is INSERT IGNORE; on H2 use MERGE, or better, run Postgres in Testcontainers, which you want anyway for the integration test in week six.
Three things in that method carry weight. First, the debit writes a payment row. "Reverse payment 7f3a" is a very different instruction from "add 400 back to a wallet", and the second one cannot be made safe. Second, the topic keys: key each topic by the id of the thing its consumer is going to mutate. order.created and payment.refund both end with the payment service changing one wallet, so both are keyed by userId, which puts every event touching that wallet on one partition, in order, behind one consumer thread. payment.completed, payment.failed, stock.reserved and stock.reservation.failed all mutate one saga row, so they are keyed by orderId. Third, partitioning is not a lock. A rebalance can hand a partition to another instance while your listener is still running, so the wallet is read with PESSIMISTIC_WRITE anyway. Read-modify-write on a balance with no lock is a lost update, and it is the bug most likely to actually show up in your demo.
The outbox is what makes the dedupe table safe
The outbox is one table and one poller. Inside the listener transaction you do not talk to Kafka at all; you write the event as a row. The debit, the dedupe row and the outbox row commit together or none of them do, so an event cannot exist without the write it describes, and a write cannot exist without the event. A separate poller reads unsent rows and publishes them. If the poller dies after the broker acks but before it marks the row sent, it publishes the same event again on restart, and the consumer's dedupe table absorbs it. Every hop is at-least-once and every consumer is idempotent, so the loop closes. Make the outbox row id the event id: that one decision is what lets the saga sweeper re-drive a command later without minting a new id.
The saga: how do you refund a payment when inventory says no?
There is no rollback across three databases. What you have instead is a saga: a sequence of local transactions, each with a compensating transaction that semantically undoes it. Money debited is not un-debited by a ROLLBACK; it is refunded by a second, forward transaction. Choose orchestration over choreography. In choreography each service listens and reacts and the flow lives nowhere. In orchestration one service owns a persisted state machine and issues commands, which is easier to reason about, easier to draw, and easier to recover after a crash, because the state is in a table you can query in front of the examiner.
Two details to defend out loud. The pessimistic lock on the saga row is not decoration: payment.completed and stock.reservation.failed arrive on different partitions and therefore different container threads, so without the lock both handlers can read the same pre-transition step and both act. That is the identical lost update the wallet has, committed in the one component whose state must not be corrupted. And the order service starts the saga the same way the payment service continues it: in one transaction it writes the Order row as PENDING, the SagaState row as AWAITING_PAYMENT with lastCommandId set to the OrderCreated event id, and the outbox row carrying that event, keyed by userId. The compensating step lives in the payment service and is idempotent for exactly the same reason the debit was.
A saga is not atomic, and you will be asked about it. Between the debit and the refund there is a window, possibly seconds long, where the customer's balance is wrong. That is eventual consistency: a property of your system, not a bug you failed to fix. Say it plainly, then say what you did about visibility. The order sits in PENDING for the whole window and the UI never shows it as confirmed, so no user-visible decision is ever made on a state the system has not yet reconciled.
What if the orchestrator itself crashes?
The saga state is persisted before any command leaves the outbox, so a crash loses nothing. What a crash can do is leave a saga parked: the inventory service was down long enough for its record to be dropped, and no reply is ever coming. So put a number on it. The step timeout is thirty seconds, the sweeper runs every ten, and after five re-drives the saga is marked STALLED and appears on an admin page for a human. Those are the numbers, and they are the ones to quote when the examiner asks how long a wrong balance can persist. The critical line is the re-drive itself: it republishes the command you already issued, under its original event id. A fresh id walks straight past the consumer's dedupe table and reserves the stock, or refunds the money, a second time. Your own retry would defeat your own idempotency.
The dead-letter topic: what Spring actually does by default
You will read that a poison message blocks the partition forever. It does not. Spring Boot configures DefaultErrorHandler with FixedBackOff(0, 9): ten delivery attempts, no delay, and then the default recoverer logs the record at ERROR, the container commits the offset, and the consumer moves on. The partition keeps flowing. What actually happens is worse than a hang, because a hang is something you notice. Your event is gone, the only evidence is a stack trace in a container log nobody is tailing, and the saga waiting on it sits in AWAITING_PAYMENT until the sweeper gives up on it. The dead-letter topic is not there to unblock the partition. It is there so the record still exists after everything else has failed.
Boot picks up a single CommonErrorHandler bean automatically, so that is all the wiring. Two things to add. Configure ErrorHandlingDeserializer on the consumer, otherwise a record whose JSON does not parse fails before your listener is ever called and the error handler has nothing to recover. Then build the boring part that turns a config file into a project: an admin endpoint that lists what is sitting in each DLT and why, reading the exception class and message out of the headers Spring adds to the dead-lettered record, alongside the sagas the sweeper has marked STALLED. That page is the one screenshot in your report that proves you thought about the day after the demo.
Distributed tracing: one trace ID through three services
Add micrometer-tracing-bridge-brave and zipkin-reporter-brave, run Zipkin in Docker, and turn on Kafka observation. Spring propagates the trace context in the Kafka record headers, so one order becomes one trace spanning the HTTP call, the producer, the consumer and the database. Boot puts traceId and spanId into the log pattern for you once a tracer is on the classpath; the pattern below is what to set if you have overridden it yourself. One honest caveat the outbox introduces: the poller publishes on a scheduled thread, so the producer span is not a child of the request that wrote the row unless you store the W3C traceparent value in the outbox row and set it back on the ProducerRecord when you publish. Do that, or say in the report that you know the trace breaks at the outbox and why.
What to skip
- Kubernetes. Docker Compose runs the whole system in one file you can read out loud. A Helm chart demonstrates deployment tooling, not distributed systems, and that is not what you are being marked on.
- A service mesh. Istio solves a problem you do not have and adds a control plane you will spend a week debugging.
- Custom or dedicated service discovery. With Kafka, services do not call each other by address; only the gateway needs to reach them, and in Docker Compose that is a service name in a YAML file. Eureka is in these projects because the tutorial had it.
- A config server. Environment variables in a Compose file are fine and you can explain them in one sentence.
- Event sourcing plus CQRS on top of everything else. Pick one hard thing and do it properly. Two hard things done badly is worse than one done well.
- More than three or four services. Each extra service is more YAML and no additional insight.
Demo failure and recovery, not the happy path
- Place two concurrent orders for the last unit in stock. One saga completes, one compensates, and the wallet balance is correct at the end. That is the pessimistic lock and the saga doing their jobs at the same time.
- Set the outbox poller interval to five seconds for the rehearsal, place an order, and run docker kill payment-service after the debit commits but before the poller runs. Show the wallet debited, the payment row written, the outbox row with sent_at NULL, and the saga still in AWAITING_PAYMENT, because payment.completed was never published. Restart the container: the poller flushes the row, the saga advances, the order confirms. That is the outbox earning its place, and without it that debit is lost forever.
- Re-publish an order.created event by hand with an event id you have already used. Show markProcessed returning 0, the skip in the log, and the balance not moving. At-least-once delivery survived rather than avoided.
- Publish an event with a null SKU. Show three retries, the record landing in stock.reserve.DLT with the exception in the headers, the next valid record still being processed, and then the saga for that order turning up as STALLED on your admin page after five sweeps. Point out that with Spring's default handler that record would have been logged once and dropped.
- Open Zipkin and show one trace, with spans across all three services, for the order you just placed.
Scope this to the weeks you actually have
Two weeks for three services with Kafka wired up and one happy-path saga. One week for idempotency, the dedupe table and the outbox. One week for compensation, the payment.failed path and the sweeper. One week for the dead-letter topic, tracing and the admin page. One week for tests, in particular a Testcontainers integration test that kills a container mid-saga so your recovery claim is verified rather than asserted. That is six weeks of real work and a defensible project. The version with Kubernetes and eight services is ten weeks and a worse story.
If you want a second pair of eyes on the design before you commit six weeks to it, that is the kind of review we offer at Tenzok. The design above is yours to build without us, and it holds up on its own.
Frequently asked