Skip to content
Tenzok
Home
Services
Student Projects
BlogAboutContact
All insights
Engineering insight14 July 2026·13 min read

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.

Spring BootMicroservicesKafkaCapstone

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.

java
// WRONG: the debit is not idempotent.
// Kafka redelivers on restart and this method debits the wallet a second time.
@Component
public class PaymentListener {

    private final WalletRepository wallets;

    public PaymentListener(WalletRepository wallets) {
        this.wallets = wallets;
    }

    @KafkaListener(topics = "order.created", groupId = "payment-service")
    public void onOrderCreated(OrderCreated event) {
        Wallet wallet = wallets.findByUserId(event.userId()).orElseThrow();
        wallet.setBalance(wallet.getBalance().subtract(event.amount()));
        wallets.save(wallet);
        // Crash here -> the offset is never committed -> on restart the same
        // record is delivered again -> the customer pays twice.
    }
}

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.

sql
-- Postgres.
CREATE TABLE processed_event (
    event_id     UUID        NOT NULL,
    consumer     VARCHAR(64) NOT NULL,
    processed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    PRIMARY KEY (event_id, consumer)
);
java
// The contract. One record per file in a shared module; shown together here.
// Every event carries its own id: that id is what consumers dedupe on and what
// the outbox row is keyed by.
public record OrderCreated(UUID eventId, UUID orderId, UUID userId,
                          String sku, int quantity, BigDecimal amount) {}
public record PaymentCompleted(UUID eventId, UUID paymentId, UUID orderId,
                              UUID userId, BigDecimal amount) {}
public record PaymentFailed(UUID eventId, UUID orderId, UUID userId, String reason) {}
public record ReserveStock(UUID eventId, UUID orderId, String sku, int quantity) {}
public record StockReserved(UUID eventId, UUID orderId, String sku, int quantity) {}
public record StockReservationFailed(UUID eventId, UUID orderId, String reason) {}
public record RefundPayment(UUID eventId, UUID orderId, UUID paymentId, UUID userId) {}
public record PaymentRefunded(UUID eventId, UUID orderId, UUID paymentId) {}

// Entities (Wallet, Payment, SagaState, Order, OutboxEvent, ProcessedEvent) are
// ordinary JPA classes and are not shown.

public interface ProcessedEventRepository
        extends JpaRepository<ProcessedEvent, ProcessedEventId> {

    // Returns 1 the first time, 0 for a redelivery. Postgres syntax.
    @Modifying
    @Query(value = """
            INSERT INTO processed_event (event_id, consumer)
            VALUES (:eventId, :consumer)
            ON CONFLICT DO NOTHING
            """, nativeQuery = true)
    int markProcessed(@Param("eventId") UUID eventId, @Param("consumer") String consumer);
}

public interface WalletRepository extends JpaRepository<Wallet, UUID> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select w from Wallet w where w.userId = :userId")
    Optional<Wallet> lockByUserId(@Param("userId") UUID userId);
}

@Component
public class PaymentListener {

    private static final Logger log = LoggerFactory.getLogger(PaymentListener.class);
    private static final String CONSUMER = "payment-service";

    private final ProcessedEventRepository processed;
    private final WalletRepository wallets;
    private final PaymentRepository payments;
    private final Outbox outbox;

    public PaymentListener(ProcessedEventRepository processed,
                           WalletRepository wallets,
                           PaymentRepository payments,
                           Outbox outbox) {
        this.processed = processed;
        this.wallets = wallets;
        this.payments = payments;
        this.outbox = outbox;
    }

    // Dedupe row + debit + payment row + outbox row commit together, or not at all.
    @KafkaListener(topics = "order.created", groupId = CONSUMER)
    @Transactional
    public void onOrderCreated(OrderCreated event) {
        if (processed.markProcessed(event.eventId(), CONSUMER) == 0) {
            log.info("Duplicate delivery of {}, skipping", event.eventId());
            return;
        }

        Wallet wallet = wallets.lockByUserId(event.userId())
                .orElseThrow(() -> new UnknownWalletException(event.userId()));

        if (wallet.getBalance().compareTo(event.amount()) < 0) {
            UUID rejected = UUID.randomUUID();
            outbox.publish(rejected, "payment.failed", event.orderId().toString(),
                    new PaymentFailed(rejected, event.orderId(),
                            event.userId(), "INSUFFICIENT_FUNDS"));
            return;
        }

        wallet.setBalance(wallet.getBalance().subtract(event.amount()));
        wallets.save(wallet);

        // The debit is a row, not just a smaller balance. This id is what the
        // refund will point at.
        Payment payment = payments.save(new Payment(UUID.randomUUID(), event.orderId(),
                event.userId(), event.amount(), PaymentStatus.DEBITED));

        UUID eventId = UUID.randomUUID();
        outbox.publish(eventId, "payment.completed", event.orderId().toString(),
                new PaymentCompleted(eventId, payment.getId(), event.orderId(),
                        event.userId(), event.amount()));
    }
}

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.

A plain KafkaTemplate.send() is not enrolled in your database transaction, in either direction. Send before the commit and a rollback still leaves the event published: downstream services act on a debit that never happened. Send after the commit and a crash in between loses the event entirely. The second failure is worse than it looks once you have a dedupe table, because the dedupe table makes it permanent: on redelivery, markProcessed returns 0, the listener returns early, payment.completed is never re-emitted, the wallet stays debited and the saga waits forever. Dedupe-and-return is only safe if the outgoing event is durable. That is what the next section is for, and it is why the code above calls outbox.publish rather than kafka.send.

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.

sql
-- Postgres. FOR UPDATE SKIP LOCKED in the poller query is what lets you run
-- more than one instance without publishing the same row twice.
CREATE TABLE outbox_event (
    id            UUID         PRIMARY KEY,   -- this IS the event id inside the payload
    topic         VARCHAR(128) NOT NULL,
    partition_key VARCHAR(64)  NOT NULL,
    payload       TEXT         NOT NULL,
    payload_type  VARCHAR(256) NOT NULL,
    created_at    TIMESTAMPTZ  NOT NULL DEFAULT now(),
    sent_at       TIMESTAMPTZ
);

CREATE INDEX outbox_unsent ON outbox_event (created_at) WHERE sent_at IS NULL;
java
public interface OutboxEventRepository extends JpaRepository<OutboxEvent, UUID> {

    @Query(value = """
            SELECT * FROM outbox_event
            WHERE sent_at IS NULL
            ORDER BY created_at
            LIMIT 100
            FOR UPDATE SKIP LOCKED
            """, nativeQuery = true)
    List<OutboxEvent> lockUnsent();

    // Used by the saga sweeper to re-publish a command with its original id.
    @Modifying
    @Query("update OutboxEvent e set e.sentAt = null where e.id = :id")
    int markUnsent(@Param("id") UUID id);
}

@Component
public class Outbox {

    private final OutboxEventRepository repo;
    private final ObjectMapper mapper;

    public Outbox(OutboxEventRepository repo, ObjectMapper mapper) {
        this.repo = repo;
        this.mapper = mapper;
    }

    // Called from inside a listener's @Transactional method. No I/O, just a row.
    public void publish(UUID eventId, String topic, String key, Object payload) {
        try {
            repo.save(new OutboxEvent(eventId, topic, key,
                    mapper.writeValueAsString(payload), payload.getClass().getName()));
        } catch (JsonProcessingException e) {
            throw new IllegalStateException("Cannot serialise " + payload, e);
        }
    }
}

@Component
public class OutboxPoller {

    private final OutboxEventRepository repo;
    private final KafkaTemplate<String, Object> kafka;
    private final ObjectMapper mapper;

    public OutboxPoller(OutboxEventRepository repo,
                        KafkaTemplate<String, Object> kafka,
                        ObjectMapper mapper) {
        this.repo = repo;
        this.kafka = kafka;
        this.mapper = mapper;
    }

    // Needs @EnableScheduling on the application class.
    @Scheduled(fixedDelay = 500)
    @Transactional
    public void flush() {
        for (OutboxEvent row : repo.lockUnsent()) {
            try {
                Object payload = mapper.readValue(row.getPayload(),
                        Class.forName(row.getPayloadType()));
                // Block on the ack: mark it sent only once the broker has it.
                kafka.send(row.getTopic(), row.getPartitionKey(), payload)
                        .get(5, TimeUnit.SECONDS);
                row.setSentAt(Instant.now());   // managed entity: flushes on commit
            } catch (Exception e) {
                // Roll the batch back. The rows stay unsent and go again next tick.
                // Anything already published gets published twice; consumers dedupe.
                throw new IllegalStateException("Outbox publish failed for " + row.getId(), e);
            }
        }
    }
}

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.

java
public interface SagaStateRepository extends JpaRepository<SagaState, UUID> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select s from SagaState s where s.orderId = :orderId")
    Optional<SagaState> lockByOrderId(@Param("orderId") UUID orderId);
}

// Order service. Step: AWAITING_PAYMENT, RESERVING_STOCK, COMPLETED,
// COMPENSATING, COMPENSATED, FAILED, STALLED.
@Component
public class OrderSaga {

    private static final String CONSUMER = "order-service";

    private final SagaStateRepository sagas;
    private final OrderRepository orders;
    private final Outbox outbox;

    public OrderSaga(SagaStateRepository sagas, OrderRepository orders, Outbox outbox) {
        this.sagas = sagas;
        this.orders = orders;
        this.outbox = outbox;
    }

    // A row lock, not just a step check. These listeners are bound to different
    // topics, so they run on different container threads and can arrive together.
    // Empty means the saga is not in the step this event belongs to: ignore it.
    private Optional<SagaState> lockIfAt(UUID orderId, Step expected) {
        return sagas.lockByOrderId(orderId).filter(saga -> saga.getStep() == expected);
    }

    @KafkaListener(topics = "payment.completed", groupId = CONSUMER)
    @Transactional
    public void onPaymentCompleted(PaymentCompleted event) {
        lockIfAt(event.orderId(), Step.AWAITING_PAYMENT).ifPresent(saga -> {
            saga.setPaymentId(event.paymentId());   // what a refund would point at
            saga.setStep(Step.RESERVING_STOCK);

            UUID commandId = UUID.randomUUID();
            saga.setLastCommandId(commandId);       // the sweeper re-drives THIS id
            saga.setUpdatedAt(Instant.now());
            sagas.save(saga);

            outbox.publish(commandId, "stock.reserve", saga.getSku(),
                    new ReserveStock(commandId, saga.getOrderId(),
                            saga.getSku(), saga.getQuantity()));
        });
    }

    // The likeliest failure in the whole system, and the one most projects forget:
    // the customer does not have the money.
    @KafkaListener(topics = "payment.failed", groupId = CONSUMER)
    @Transactional
    public void onPaymentFailed(PaymentFailed event) {
        lockIfAt(event.orderId(), Step.AWAITING_PAYMENT).ifPresent(saga -> {
            saga.setStep(Step.FAILED);              // terminal: nothing was debited
            saga.setFailureReason(event.reason());
            saga.setUpdatedAt(Instant.now());
            sagas.save(saga);
            close(saga.getOrderId(), OrderStatus.REJECTED);
        });
    }

    @KafkaListener(topics = "stock.reserved", groupId = CONSUMER)
    @Transactional
    public void onStockReserved(StockReserved event) {
        lockIfAt(event.orderId(), Step.RESERVING_STOCK).ifPresent(saga -> {
            saga.setStep(Step.COMPLETED);
            saga.setUpdatedAt(Instant.now());
            sagas.save(saga);
            close(saga.getOrderId(), OrderStatus.CONFIRMED);
        });
    }

    // Compensation trigger: the money is gone and the stock is not there.
    @KafkaListener(topics = "stock.reservation.failed", groupId = CONSUMER)
    @Transactional
    public void onStockReservationFailed(StockReservationFailed event) {
        lockIfAt(event.orderId(), Step.RESERVING_STOCK).ifPresent(saga -> {
            saga.setStep(Step.COMPENSATING);
            saga.setFailureReason(event.reason());

            UUID commandId = UUID.randomUUID();
            saga.setLastCommandId(commandId);
            saga.setUpdatedAt(Instant.now());
            sagas.save(saga);

            // Keyed by userId, not orderId: this command mutates a wallet, and every
            // event that mutates one wallet must land on one partition, in order.
            outbox.publish(commandId, "payment.refund", saga.getUserId().toString(),
                    new RefundPayment(commandId, saga.getOrderId(),
                            saga.getPaymentId(), saga.getUserId()));
        });
    }

    @KafkaListener(topics = "payment.refunded", groupId = CONSUMER)
    @Transactional
    public void onPaymentRefunded(PaymentRefunded event) {
        lockIfAt(event.orderId(), Step.COMPENSATING).ifPresent(saga -> {
            saga.setStep(Step.COMPENSATED);         // terminal, and the money is back
            saga.setUpdatedAt(Instant.now());
            sagas.save(saga);
            close(saga.getOrderId(), OrderStatus.REJECTED);
        });
    }

    private void close(UUID orderId, OrderStatus status) {
        Order order = orders.findById(orderId).orElseThrow();
        order.setStatus(status);                    // same database, same transaction
        orders.save(order);
    }
}

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.

java
// Payment service. This method lives inside the PaymentListener class shown
// earlier: same dedupe table, same wallet lock, same outbox.
@KafkaListener(topics = "payment.refund", groupId = CONSUMER)
@Transactional
public void onRefundPayment(RefundPayment cmd) {
    if (processed.markProcessed(cmd.eventId(), CONSUMER) == 0) {
        return;   // the sweeper re-drove the command; this refund already happened
    }

    // The command names the payment. The payment row holds the amount, so a stale
    // saga cannot make you refund the wrong number.
    Payment payment = payments.findById(cmd.paymentId())
            .orElseThrow(() -> new IllegalStateException("No payment " + cmd.paymentId()));
    if (payment.getStatus() == PaymentStatus.REFUNDED) {
        return;   // second line of defence: the payment row itself
    }

    Wallet wallet = wallets.lockByUserId(cmd.userId())
            .orElseThrow(() -> new UnknownWalletException(cmd.userId()));
    wallet.setBalance(wallet.getBalance().add(payment.getAmount()));
    wallets.save(wallet);

    payment.setStatus(PaymentStatus.REFUNDED);
    payments.save(payment);

    UUID eventId = UUID.randomUUID();
    outbox.publish(eventId, "payment.refunded", cmd.orderId().toString(),
            new PaymentRefunded(eventId, cmd.orderId(), cmd.paymentId()));
}

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.

java
// Add to SagaStateRepository:
//
//   @Query(value = """
//           SELECT * FROM saga_state
//           WHERE step IN ('AWAITING_PAYMENT', 'RESERVING_STOCK', 'COMPENSATING')
//             AND updated_at < :cutoff
//           ORDER BY updated_at LIMIT 100
//           FOR UPDATE SKIP LOCKED
//           """, nativeQuery = true)
//   List<SagaState> lockStuck(@Param("cutoff") Instant cutoff);

@Component
public class SagaSweeper {

    // The saga timeout. Write it down: the examiner will ask for the number.
    private static final Duration STEP_TIMEOUT = Duration.ofSeconds(30);
    private static final int MAX_ATTEMPTS = 5;

    private final SagaStateRepository sagas;
    private final OutboxEventRepository outbox;

    public SagaSweeper(SagaStateRepository sagas, OutboxEventRepository outbox) {
        this.sagas = sagas;
        this.outbox = outbox;
    }

    @Scheduled(fixedDelay = 10_000)
    @Transactional
    public void redriveStuckSagas() {
        Instant cutoff = Instant.now().minus(STEP_TIMEOUT);
        for (SagaState saga : sagas.lockStuck(cutoff)) {
            if (saga.getAttempts() >= MAX_ATTEMPTS) {
                saga.setStep(Step.STALLED);   // a human looks at it on the admin page
                sagas.save(saga);
                continue;
            }

            // Re-drive the command we already issued, with the SAME event id.
            // A new id would slip past the consumer's dedupe table and reserve the
            // stock, or refund the money, twice.
            outbox.markUnsent(saga.getLastCommandId());
            saga.setAttempts(saga.getAttempts() + 1);
            saga.setUpdatedAt(Instant.now());
            sagas.save(saga);
        }
    }
}

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.

java
// Spring Boot 3.x. ExponentialBackOffWithMaxRetries works across the whole Boot 3
// line; ExponentialBackOff.setMaxAttempts() only exists from Boot 3.2.
@Configuration
public class KafkaErrorHandlingConfig {

    @Bean
    public DefaultErrorHandler errorHandler(KafkaTemplate<String, Object> template) {
        // Name the DLT explicitly so the topic name is predictable and you can create
        // it up front with the same partition count as the source topic. The recoverer
        // reuses the source partition number, so a DLT with fewer partitions throws.
        DeadLetterPublishingRecoverer recoverer = new DeadLetterPublishingRecoverer(
                template,
                (record, exception) ->
                        new TopicPartition(record.topic() + ".DLT", record.partition()));

        ExponentialBackOffWithMaxRetries backOff = new ExponentialBackOffWithMaxRetries(3);
        backOff.setInitialInterval(500L);
        backOff.setMultiplier(2.0);
        backOff.setMaxInterval(10_000L);

        DefaultErrorHandler handler = new DefaultErrorHandler(recoverer, backOff);

        // A wallet that does not exist will not start existing on the third attempt.
        handler.addNotRetryableExceptions(UnknownWalletException.class);
        return handler;
    }
}

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.

yaml
management:
  tracing:
    sampling:
      probability: 1.0   # sample everything; this is a demo, not production
  zipkin:
    tracing:
      endpoint: http://localhost:9411/api/v2/spans

spring:
  kafka:
    template:
      observation-enabled: true
    listener:
      observation-enabled: true

logging:
  pattern:
    level: "%5p [${spring.application.name:},%X{traceId:-},%X{spanId:-}]"

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

  1. 1Place 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.
  2. 2Set 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.
  3. 3Re-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.
  4. 4Publish 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.
  5. 5Open 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

Questions people actually ask

Is a Spring Boot microservices project a good final year project?

Only if the project is genuinely about distributed systems. Microservices solve an organisational problem (many teams deploying independently) that a student team does not have. If your real contribution is elsewhere, build a modular monolith and defend that choice in the viva. If you do choose microservices, the project has to demonstrate asynchronous messaging, idempotent consumers, a transactional outbox, a saga with working compensation, a dead-letter topic and distributed tracing. Without those it is three CRUD apps in Docker.

How do I make a Kafka consumer idempotent in Spring Boot?

Give every event a UUID when it is published. In the consumer, insert that UUID into a processed_event table keyed on (event_id, consumer) and run that insert in the same @Transactional method as the business write. On Postgres use INSERT ... ON CONFLICT DO NOTHING; on MySQL, INSERT IGNORE. If the insert affects zero rows the record is a redelivery, so you return. Returning early is only safe if the events you would have published are written to an outbox table in that same transaction, otherwise a duplicate delivery silently swallows an event you never managed to send.

What is the saga pattern and why do I need it in a microservices project?

A saga is a sequence of local database transactions across services, each paired with a compensating transaction that semantically undoes it. You need it because there is no rollback across three separate databases: a debited wallet is not un-debited by a ROLLBACK, it is refunded by a second forward transaction. Use an orchestrator with a state machine persisted in a table, lock the saga row when you transition it, and handle every failure branch, including the payment simply being declined.

What does Spring Boot do with a Kafka message that keeps failing?

By default, DefaultErrorHandler retries the record ten times with no backoff (FixedBackOff(0, 9)), then the default recoverer logs it at ERROR, the container commits the offset and moves on. The partition is not blocked, but the record is gone and only a log line remembers it. Configure a DeadLetterPublishingRecoverer so the failed record is republished to a .DLT topic with the exception in the headers, and create the DLT with the same partition count as the source topic.

What happens if a service crashes in the middle of a saga?

Nothing is lost if the saga state is persisted before any command is published and every event leaves through an outbox table. The saga simply waits. The window in which balances are inconsistent is bounded by how fast your sweeper reclaims stuck sagas, so set that interval deliberately and be able to state it: a thirty second step timeout, a sweeper every ten seconds, and after five re-drives the saga is marked STALLED for a human. Re-drives must republish the original command under its original event id, or your own retry will defeat your own dedupe table.

Apply it to your project

Stuck on this in your own build?

This is the kind of problem we work through in code reviews every week. Send the problem statement and we’ll tell you honestly whether the scope is right.

Talk to us

On this page

  • Should your final year project be microservices at all?
  • What turns three CRUD apps in Docker into a distributed-systems project
  • Your Kafka consumer will run twice. Here is the wrong version.
  • The idempotent consumer, properly
  • The outbox is what makes the dedupe table safe
  • The saga: how do you refund a payment when inventory says no?
  • The dead-letter topic: what Spring actually does by default
  • Distributed tracing: one trace ID through three services
  • What to skip
  • Demo failure and recovery, not the happy path
  • Scope this to the weeks you actually have

Need a second opinion?

Send the problem statement directly to the Tenzok team.

Email us

Keep reading

Related engineering notes

Browse all insights

8 July 2026 · 11 min

12 Viva Questions Examiners Ask About Your Final Year Project

Viva questions cluster into a few recognisable families, and in most vivas every one of them ends with the same follow-up: show me where that happens in the code.

Read article

1 July 2026 · 11 min

How to Deploy Your Final Year Project to a Real URL

A localhost screenshot says "I got it working once." A live URL says "this runs without me." Here is the shortest honest path from your laptop to a real deployment: Docker, secrets, a health check, TLS, and CI/CD that ships on merge.

Read article

23 June 2026 · 12 min

How to Build a RAG Chatbot That Actually Retrieves

Most RAG chatbot projects fail at retrieval, not generation — here is how to chunk on structure, store in pgvector, measure recall@k, and build a refusal path that actually fires.

Read article
Your next build starts here

Turn the idea into software people trust.

Bring us a product brief, a business problem, or a final-year project. We’ll turn it into a clear scope, a working build, and a handover you fully own.

Start Your ProjectSend your brief

Prefer email? info@tenzok.in

Tenzok

A product engineering studio for ambitious companies, founders, and students who want real, production-minded work.

info@tenzok.in

Company

HomeBlogAboutContactFAQ

Services

MentorshipStudent ProjectsCompany ServicesDigital MarketingLaunch Support

Project domains

Python Full-StackJava & EnterpriseAI & LLM ApplicationsMachine LearningExplore all 18 domainsRSS feed

© 2026 Tenzok. All rights reserved.

Obsession · Purpose · Excellence

Published by Tenzok. Contact info@tenzok.in.