Portfolio Manifest — Prepared 2026-08-19
Backend & Distributed Systems Engineer
An original implementation of the Distributed Saga pattern across six independent Spring Boot / gRPC microservices — a JWT-guarded API gateway in front of order placement, payment settlement, and restaurant fulfillment, coordinated through Kafka with compensating transactions instead of a shared database transaction. Built and verified one service at a time against real Postgres and Kafka infrastructure — not mocked past the point where mocking stops proving anything.
A whiteboard topic, built for real
Saga orchestration is a common interview-whiteboard topic and an uncommon thing to actually build end to end: compensating transactions, event ordering, idempotency, and the failure modes that only show up once services genuinely run independently. This isn't a fork of any existing project — the module boundaries and general shape of the problem (order → payment → fulfillment, with compensation on failure) are common territory for this class of system, but the code, design decisions, and tradeoffs recorded here are this repo's own, built one service at a time with the reasoning written down as it happened.
Outbox pattern, compensating transactions, wired end-to-end behind a real security perimeter
api-gateway-service fronts the chain — JWT-guarded
routing and a Resilience4j circuit breaker sit in front of everything
below. order-service creates an order and stages an
outbox record in the same transaction; a poller ships it to Kafka.
payment-service consumes it, settles payment, and
publishes its own event. restaurant-service consumes
that, allocates inventory, and makes the saga's real decision —
approve or reject — publishing back to both
order-service and payment-service so a
rejection compensates the payment and cancels the order. No shared
database transaction anywhere in the chain.
spring-kafka directly instead of a Spring Cloud
Stream binder — this project only ever targets Kafka, so the
broker-swappability abstraction wasn't worth the config overhead.
UUID ids and BigDecimal money instead of
raw strings and doubles — floating point can't exactly represent
decimal fractions, a real correctness concern once money's
involved. Reasoning for both recorded in
docs/architecture.md.
Every state-changing method follows the same cheap-check-before-load
pattern: an existsBy… query first, then a status
re-check after load, so a duplicate Kafka delivery — the normal
case for at-least-once delivery, not an edge case — can't double-
apply an order confirmation, a payment, or a compensation.
Found via actually running the thing, not reading the source twice
Every entry below was caught by a real, failing run — a test, a CI job, or a live boot against real infrastructure — not by static reading, and every fix was re-verified with another real run before being called done.
spring-grpc's BOM was silently pulling
protobuf-java below what user-contract's
generated code needs, on user-service's actual
runtime classpath — not just tests — since the module's first
phase. Undetected until a test finally constructed a generated
message at runtime. Fixed with an explicit version pin.
contextLoads) failed
outright — Spring Framework's own bundled ASM can't parse JDK 25
class files during component scanning at all, deeper than an
earlier Gradle-plugin-only version of the same issue. Checking
user-service showed it had the identical latent
risk, never caught because no test there had ever booted a real
ApplicationContext. Fixed by bumping both modules to
Boot 3.5.16.
Permission
denied, exit 126) — gradlew had been
committed from Windows without its executable bit, so git stored
mode 100644 instead of 100755. Fixed
with git update-index --chmod=+x, confirmed on a
second real run.
JwtPerimeterGuardGatewayFilterFactoryTest
and actually running it — never assumed — showed two of its
three cases asserting an HTTP response status the filter itself
never sets; that translation only happens through
GlobalExceptionHandler, a layer absent from an
isolated filter unit test. Failed immediately with
onError instead of the expected
onComplete. Rewritten to assert the filter's real,
provable contract instead.
OrderController logging a
whole request record whose itemCode field is
free-form client input with no character restriction —
\r/\n bytes could forge fake log
lines. Fixed by logging fields individually and stripping
control characters from the one field that actually needed it.
PasswordEncoder with a literal
placeholder hash — so the real bcrypt encode()
path had never run once, in production code or tests. Added
a real encode/matches round-trip test.
Login returned three distinguishable gRPC
statuses depending on unknown-email vs. wrong-password vs.
inactive-account, letting a caller enumerate registered
emails — and their active status — without ever guessing a
password. Collapsed to one generic UNAUTHENTICATED
response after weighing the UX tradeoff.
api-gateway-service already
caches its JWTVerifier once at construction for
performance; this file rebuilt one on every single
verifyToken() call. Matched the established
pattern — zero behavior change, existing tests passed
unchanged as proof.
user-service
had no @DataJpaTest coverage at all —
findByEmail and the email column's
unique constraint had never been verified against a real
database. Added tests including a real proof the unique
constraint is genuinely enforced.
itemCode field CodeQL
had already flagged once (item 06) turned out to have a second,
cross-service path to an unsanitized log line:
order-service → payment-service →
restaurant-service's raw string-concatenated
rejection reason → back into order-service's own
cancelOrder. Traced end-to-end before being fixed
with the same sanitizing pattern already established for the
first instance.
customerId (and
one carried ticketId) all the way into
confirmOrder/cancelOrder without
either method ever checking them — a real asymmetry against
this repo's own established pattern of validating every field
on an inbound event. Fixed by cross-checking
customerId against the order already on record,
catching a corrupted or mismatched message on the shared topic
instead of trusting it blindly.
Future.get(), implicitly relying on Kafka's own
undocumented 120-second client default while holding a database
row lock open the whole time — a degraded broker could have
held that lock for up to 20 minutes across a full batch. Fixed
with an explicit, bounded timeout.
Real Postgres, real Kafka, real partition assignments — not assumed
Every Kafka-wired service was booted for real against Docker Compose's
Postgres and Kafka containers, not just unit-tested in isolation: a
genuine HikariPool→PgConnection connection,
and each service's consumer groups actually joining and getting real
partition assignments against the live broker — the thing every prior
test run under a mocked or absent broker couldn't prove.
Repository tests run against embedded H2 with real Hibernate DDL, not mocks; Kafka/Postgres wiring is separately verified against the actual Docker containers before being called done.
A consolidated test report, regenerated and committed back to main by CI after
every real test run — checkable against the live repository, not
a static screenshot.
Where the interesting decisions actually happened
GitHub Pages was briefly switched to an Actions-based deployment to
try matching this portfolio's own live-rendered-site pattern, then
deliberately reverted back to a simpler committed-static-file
approach once it became clear the fancier setup would replace the
README-rendered repo homepage for no real benefit at this stage —
matching a pattern isn't a reason to add complexity a project doesn't
need yet. Separately, building out payment-service before
restaurant-service wasn't the obvious reading of the
original four-service plan — it came from actually checking which
service consumes which event, not from the plan's stated order.
restaurant-service depends on payment-service's
output, not the reverse, so building it first would have meant
building against nothing. Both calls, and the reasoning behind them,
are recorded in
todo.md
as they happened, not reconstructed afterward.