Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/_quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ name: Quality Assurance
on:
workflow_call:

permissions:
contents: read

jobs:
static-analysis-and-tests:
name: Code Quality & Unit Tests
Expand All @@ -19,7 +22,7 @@ jobs:
distribution: 'temurin'
cache: 'maven'

# 1. Spotless: Verifies code formatting
# 1. Spotless: Verifies code formatting without mutating the checkout
- name: Run Spotless
run: ./mvnw spotless:check

Expand Down
20 changes: 15 additions & 5 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,27 +22,37 @@ jobs:

steps:
- name: Checkout Repository
uses: actions/checkout@v4
uses: actions/checkout@v7

- name: Detect local act runtime
id: runtime
shell: bash
run: |
if [ "${ACT:-}" = "true" ]; then
echo "under_act=true" >> "$GITHUB_OUTPUT"
else
echo "under_act=false" >> "$GITHUB_OUTPUT"
fi

- name: Initialize CodeQL
if: ${{ env.ACT != 'true' }}
if: ${{ steps.runtime.outputs.under_act != 'true' }}
uses: github/codeql-action/init@v3
with:
languages: java-kotlin
build-mode: autobuild

- name: Autobuild
if: ${{ env.ACT != 'true' }}
if: ${{ steps.runtime.outputs.under_act != 'true' }}
uses: github/codeql-action/autobuild@v3

- name: Analyze
if: ${{ env.ACT != 'true' }}
if: ${{ steps.runtime.outputs.under_act != 'true' }}
uses: github/codeql-action/analyze@v3
with:
category: "/language:java-kotlin"

- name: Validate CodeQL workflow under act
if: ${{ env.ACT == 'true' }}
if: ${{ steps.runtime.outputs.under_act == 'true' }}
run: |
test -f .github/workflows/codeql.yml
test -f pom.xml
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# ADR-0003: Adopt DDD with Hexagonal Architecture across PayGuard

## Status
Accepted

## Context
PayGuard's three services (wallet, loan, collateral) each protect financial-correctness
invariants that must hold regardless of which framework, database, or transport touches them —
the double-entry balance rule, the LTV/liquidation threshold, the loan state machine. A few
concrete pressures shaped this decision:

- **Polyglot persistence per service** (JOOQ in wallet-service, JPA in loan-service and
collateral-service — see ADR-0001) means the domain model cannot afford to leak persistence
concerns into itself; if it did, each service's domain layer would look and behave differently
just because of a technology choice.
- **Invariants must be enforced in one place**, inside the aggregate, not re-implemented at every
entry point (REST controller, Kafka consumer, gRPC handler) that can trigger a mutation. A
hexagonal boundary is what makes "the domain doesn't know or care who's calling it" actually true.
- **Testability**: domain logic (state transitions, LTV math, balance invariants) needs to be unit
testable with zero infrastructure — no Testcontainers, no Spring context — which only works if
the domain has no framework/persistence dependencies to begin with.
- **This is a portfolio project built to demonstrate depth in backend architecture** for the EU
Java market; DDD + hexagonal is the architecture pattern most directly aligned with a
depth-over-breadth, strong-opinions-on-architecture positioning.

## Decision
Structure every PayGuard service as **DDD tactical patterns (Aggregates, Value Objects, Domain
Events) inside a Hexagonal (Ports & Adapters) boundary**:

- `domain` package: aggregates, value objects, domain services — no Spring, no jOOQ/JPA, no Kafka
imports.
- `application` package: use cases / orchestration, defining outbound ports (repository
interfaces, event-publisher interfaces) that the domain layer's use cases depend on.
- `adapter` package(s): inbound (REST/gRPC controllers, Kafka consumers) and outbound (jOOQ/JPA
repository implementations, Kafka producers) — these depend inward on `application`/`domain`,
never the reverse.

CQRS is deliberately **not** adopted for now (per current architecture decisions) — the
added complexity of separate read/write models isn't justified yet at this project's scale;
this can be revisited per-service if a specific read pattern demands it later.

## Consequences

**Positive**
- Domain invariants (e.g. `Transaction.post()`'s balance check, `Loan`'s state machine, `
CollateralPosition`'s LTV guard) are unit-testable in plain JUnit, no framework bootstrap needed.
- Swapping jOOQ for JPA (or vice versa) in a given service is an adapter-level change; the domain
and application layers are untouched.
- Consistent shape across all three services makes the codebase easier to onboard teammates into
and easier to narrate in an interview — the pattern doesn't change even though the persistence
tech does.

**Negative**
- More upfront structure/ceremony (ports, adapters, mapping between domain objects and
persistence records) than a simpler layered or transaction-script approach — slower to build the
first version of any given feature.
- Risk of over-engineering small, genuinely CRUD-like slices (e.g. a trivial lookup endpoint)
with the full port/adapter ceremony when a simpler pass-through would do; needs judgment per
use case rather than blind consistency.
- No CQRS means read-heavy queries (e.g. a dashboard needing joined data across aggregates) will
go through the same write-side domain model for now, which may need revisiting if query
complexity grows.

## Alternatives considered
- **Simple layered architecture (Controller → Service → Repository, anemic domain model)** —
rejected because it tends to let invariant-checking logic leak into service classes and get
duplicated or forgotten at a second entry point.
- **Full CQRS + Event Sourcing from the start** — rejected as more complexity than the current
scope justifies; the append-only ledger in wallet-service already gets much of the audit benefit
without a full event-sourced write model everywhere.
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# ADR-0002: Use Kafka (not RabbitMQ) for cross-service events

## Status
Accepted

## Context
`wallet-service`, `loan-service`, and `collateral-service` need to coordinate on events like
`LoanActivationRequested`, `CollateralLocked`, `CollateralLiquidated`, and `FundsDisbursed` — the
backbone of the loan-origination saga described in our domain-invariants discussion. Requirements
that shape the messaging choice:

- **Replay/audit**: a fintech-style system needs to be able to reconstruct "what happened and in
what order" after the fact — for reconciliation, debugging a saga that got stuck, or answering
"why is this loan in this state." A message that's gone the moment it's consumed doesn't support
that.
- **Multiple independent consumers per event**: e.g. `CollateralLiquidated` may need to be consumed
by `loan-service` (to transition the Loan) and potentially a future audit/reporting service,
without them competing for the same message.
- **Ordering per aggregate**: events about the same `Loan`/`CollateralPosition` need to be processed
in order relative to each other, which maps naturally onto partitioning by aggregate id.
- **This is a portfolio project aimed at the EU fintech job market**, where Kafka is the de facto
standard for event-driven backends — demonstrable Kafka experience (consumer groups, partitioning,
offset management, schema evolution) is more directly relevant to target roles than RabbitMQ
experience.

## Decision
Use **Kafka** as the backbone for all cross-service domain events in PayGuard (wallet-service,
loan-service, collateral-service), with topics partitioned by aggregate id (e.g. `loanId`,
`collateralPositionId`) to preserve per-aggregate ordering.

## Consequences

**Positive**
- Events are retained on the log (per configured retention), so replay for debugging, audit, or
rebuilding a read model later is possible without extra plumbing.
- Consumer groups let each service (and any future service) read the same event stream
independently, at its own pace, without competing consumption.
- Partitioning by aggregate id gives ordering guarantees exactly where the saga needs them, without
needing a global lock.
- Directly demonstrates the event-streaming skill set most relevant to the target job market.

**Negative**
- Kafka is heavier to operate than RabbitMQ (ZooKeeper/KRaft, broker configuration, topic/partition
planning) — meaningful overhead for a project run by a small team.
- Request/reply-style interactions (if any service ever needs a synchronous "ask and wait") are
awkward on Kafka compared to RabbitMQ's native RPC-style patterns; those cases should go over
gRPC instead, not be forced onto Kafka.
- No native per-message TTL/priority queues the way RabbitMQ has; anything needing that has to be
built on top (e.g. a delay via a scheduled retry topic).

## Alternatives considered
- **RabbitMQ** — better fit if the system were purely command/task-queue style with no replay need,
and operationally simpler. Rejected because audit/replay and multi-consumer fan-out are core
requirements here, not edge cases.
- **Direct synchronous calls (gRPC/REST) for saga coordination** — rejected as the primary
mechanism because it couples services' availability together and gives up the event log; gRPC is
still used for API-gateway-to-service calls where synchronous request/response is the right
shape, just not for saga coordination.
63 changes: 63 additions & 0 deletions docs/decission/ADR-0001: Use jOOQ (not JPA) in wallet-service.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# ADR-0001: Use jOOQ (not JPA) in wallet-service

## Status
Accepted

## Context
`wallet-service` owns the double-entry ledger for PayGuard: `Wallet`, `Transaction`, and
`LedgerEntry`. The invariants that matter here are financial-correctness invariants, not
convenience ones:

- Every `Transaction` must be written as a single atomic set of balanced `LedgerEntry` rows
(sum of signed amounts = 0), computed inside the domain, not left to an ORM's dirty-checking
to decide what gets flushed and when.
- `LedgerEntry` rows are append-only. Nothing about this service ever needs "load an entity,
mutate a field, let the ORM figure out the UPDATE" — that whole class of behavior is actively
something we want to *rule out*, not enable.
- Balance is derived by folding over entries (or snapshot + replay), which means the persistence
layer is doing deliberate aggregation/window queries, not simple entity graph loading.
- Optimistic locking and idempotency checks need to be explicit, single round-trip SQL statements
we can reason about precisely (`UPDATE ... WHERE version = ?`), not something implicit that an
ORM's session/`@Version` machinery does on our behalf, sometimes with lazy-loading or flush-order
surprises attached.

The other PayGuard services (`loan-service`, `collateral-service`) are closer to conventional
CRUD-with-workflow domains, where JPA's entity graph, cascades, and repository conventions save
real time and there's no analogous "invariant that ORM implicitness could quietly violate."

## Decision
Use **jOOQ** for `wallet-service` only, paired with Spring Modulith to keep the module's
persistence code physically isolated from the rest of the codebase. Use **JPA/Hibernate** in
`loan-service` and `collateral-service`, where the productivity win outweighs the precision cost.

This is a deliberate polyglot-persistence choice per service, not an inconsistency — each service
picks the tool that matches how strict its own write-path invariants are.

## Consequences

**Positive**
- Every SQL statement wallet-service issues is explicit and type-checked at compile time (jOOQ's
generated DSL), so there's no hidden N+1, no surprise flush, no lazy-loading trap around
money-moving code.
- The double-entry balance invariant is enforced in one place we control end to end: the domain
builds the `LedgerEntry` list, and the jOOQ-backed repository adapter writes exactly that, in one
statement/batch, inside one DB transaction.
- Easier to write and reason about the aggregation queries balance-derivation needs (window
functions, running totals) than in JPQL/Criteria.

**Negative**
- More boilerplate than JPA for the parts of wallet-service that *are* simple CRUD (e.g. reading a
Wallet by id) — jOOQ doesn't give us repository-pattern conveniences for free.
- Two persistence stacks in one codebase means two things to onboard a new contributor on, and two
sets of testing conventions (jOOQ's generated code vs JPA entities/repositories).
- Schema-first workflow: jOOQ code generation depends on the Oracle schema already existing/being
migrated, so migration-then-generate has to be a disciplined step in the build, not an afterthought.

## Alternatives considered
- **JPA/Hibernate everywhere** — rejected for wallet-service specifically because the implicit
dirty-checking and cascade behavior is exactly the kind of "invisible mutation path" the
double-entry invariant needs to not exist.
- **Plain JDBC / Spring `JdbcTemplate`** — rejected because it gives up jOOQ's compile-time-checked
DSL and query composition for no real benefit over jOOQ here.
- **Event sourcing the ledger itself** (rather than jOOQ over relational tables) — considered
heavier than needed for the current scope; revisit if audit/replay requirements grow.
38 changes: 38 additions & 0 deletions payguard-api-gateway/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,53 @@
<artifactId>payguard-api-gateway</artifactId>

<dependencies>
<dependency>
<groupId>dev.amg.payguard</groupId>
<artifactId>payguard-proto</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jackson</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-netty-shaded</artifactId>
</dependency>
<dependency>
<groupId>com.google.protobuf</groupId>
<artifactId>protobuf-java-util</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

<build>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package dev.amg.payguard.gateway.client;

import dev.amg.payguard.proto.collateral.v1.CollateralResponse;
import dev.amg.payguard.proto.collateral.v1.CollateralServiceGrpc;
import dev.amg.payguard.proto.collateral.v1.GetPositionRequest;
import dev.amg.payguard.proto.collateral.v1.RefreshPricesRequest;
import dev.amg.payguard.proto.collateral.v1.RefreshPricesResponse;
import dev.amg.payguard.proto.common.v1.RequestContext;
import org.springframework.stereotype.Component;

@Component
public class CollateralGatewayClient {
private final CollateralServiceGrpc.CollateralServiceBlockingStub collateral;

public CollateralGatewayClient(CollateralServiceGrpc.CollateralServiceBlockingStub collateral) {
this.collateral = collateral;
}

public CollateralResponse get(String positionId, RequestContext context) {
return collateral.getPosition(
GetPositionRequest.newBuilder().setPositionId(positionId).setContext(context).build());
}

public RefreshPricesResponse refresh(RequestContext context) {
return collateral.refreshPrices(RefreshPricesRequest.newBuilder().setContext(context).build());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package dev.amg.payguard.gateway.client;

import dev.amg.payguard.proto.common.v1.RequestContext;
import dev.amg.payguard.proto.loan.v1.GetLoanRequest;
import dev.amg.payguard.proto.loan.v1.LoanCommandRequest;
import dev.amg.payguard.proto.loan.v1.LoanResponse;
import dev.amg.payguard.proto.loan.v1.LoanServiceGrpc;
import dev.amg.payguard.proto.loan.v1.RepaymentRequest;
import dev.amg.payguard.proto.loan.v1.RepaymentResponse;
import org.springframework.stereotype.Component;

@Component
public class LoanGatewayClient {
private final LoanServiceGrpc.LoanServiceBlockingStub loan;

public LoanGatewayClient(LoanServiceGrpc.LoanServiceBlockingStub loan) {
this.loan = loan;
}

public LoanResponse get(String loanId, RequestContext context) {
return loan.getLoan(GetLoanRequest.newBuilder().setLoanId(loanId).setContext(context).build());
}

public LoanResponse approve(String loanId, RequestContext context) {
return loan.approveLoan(
LoanCommandRequest.newBuilder().setLoanId(loanId).setContext(context).build());
}

public RepaymentResponse repay(RepaymentRequest request) {
return loan.createRepayment(request);
}
}
Loading
Loading