A Spring Boot service built to explore layered architecture, dependency injection, and testing at the right level. It models a payment through a simple lifecycle created, then either completed or failed, with business rules in the service layer, PostgreSQL behind a repository interface, and a REST API on top.
Stack: Java 17 · Spring Boot 4.1 · PostgreSQL 16 · Spring Data JPA · Flyway · Maven · JUnit 5 · Mockito · Testcontainers · AssertJ
Each layer depends only on the interface below it, so the arrows also show what
a change is allowed to touch. Note which type crosses each boundary: the
immutable Payment record travels up to the controller, while PaymentEntity
never leaves the dao package.
flowchart TB
client([HTTP client, or the bundled browser page])
subgraph web["web"]
controller["PaymentController<br/>CreatePaymentRequest<br/>GlobalExceptionHandler"]
end
subgraph services["services"]
api["PaymentService<br/>interface"]
impl["PaymentServiceImpl<br/>validation, state transitions"]
end
subgraph dao["dao"]
port["PaymentDAO<br/>persistence boundary"]
adapter["JpaPaymentDAO<br/>with PaymentMapper"]
repo["PaymentJpaRepository<br/>Spring Data, package-private"]
end
db[("PostgreSQL 16<br/>schema owned by Flyway")]
client -- "JSON over REST" --> controller
controller -- "Payment" --> api
impl -. "implements" .-> api
impl -- "Payment" --> port
adapter -. "implements" .-> port
adapter -- "PaymentEntity" --> repo
repo -- "SQL" --> db
A payment moves through a three state lifecycle. The service rejects any
transition out of a terminal state with a 409, which is the rule
PaymentServiceImplTest covers most heavily.
stateDiagram-v2
[*] --> PENDING: POST /api/payments
PENDING --> COMPLETED: complete endpoint
PENDING --> FAILED: fail endpoint
COMPLETED --> [*]
FAILED --> [*]
Requires JDK 17+ and Docker. The Maven wrapper is included.
docker compose up -d # start PostgreSQL
./mvnw spring-boot:run # start the API on http://localhost:8080Flyway creates the schema on first start. Open http://localhost:8080 for a small page that creates payments and walks them through their lifecycle, or use the API directly:
curl -X POST http://localhost:8080/api/payments \
-H "Content-Type: application/json" \
-d '{"amount": 19.99, "currency": "USD"}'There is also a console walkthrough that exercises the same service:
./mvnw spring-boot:run -Dspring-boot.run.profiles=demoTests need Docker running but not the compose stack, because Testcontainers starts its own database:
./mvnw verify # tests + coverage report in target/site/jacoco| Method | Path | Purpose |
|---|---|---|
POST |
/api/payments |
Create a payment, 201 with a Location header |
GET |
/api/payments |
List all payments |
GET |
/api/payments/{id} |
Fetch one payment |
POST |
/api/payments/{id}/complete |
PENDING → COMPLETED |
POST |
/api/payments/{id}/fail |
PENDING → FAILED |
Errors come back as RFC 9457 application/problem+json:
| Status | When |
|---|---|
400 |
Amount not positive, or currency not a valid ISO 4217 code |
404 |
No payment with that id |
409 |
Transition attempted on a payment already in a terminal state |
{
"title": "Invalid state transition",
"status": 409,
"detail": "Payment 2171e7c6 is already COMPLETED and cannot become COMPLETED",
"instance": "/api/payments/2171e7c6/complete"
}src/main/java/com/bharath/core/
├── CoreApplication.java entry point
├── DemoRunner.java console walkthrough, @Profile("demo")
├── model/
│ ├── Payment.java immutable domain record
│ └── PaymentStatus.java PENDING → COMPLETED | FAILED
├── dao/
│ ├── PaymentDAO.java persistence boundary
│ ├── JpaPaymentDAO.java the only class that knows persistence is JPA
│ ├── PaymentJpaRepository.java Spring Data, package-private
│ ├── PaymentEntity.java mutable table representation
│ └── PaymentMapper.java entity to domain translation
├── services/
│ ├── PaymentService.java business operations
│ ├── PaymentServiceImpl.java validation and state transitions
│ └── PaymentNotFoundException.java
└── web/
├── PaymentController.java REST endpoints
├── CreatePaymentRequest.java request DTO with Bean Validation
└── GlobalExceptionHandler.java exception to status code mapping
src/main/resources/
├── db/migration/V1__create_payments_table.sql
└── static/index.html browser UI, no build step
The domain model and the table are separate types. Payment is an immutable
record using Currency and a status enum. PaymentEntity is a mutable class
because JPA requires a no-arg constructor and field access, and PaymentMapper
translates between them. Annotating the domain record with @Entity would force
the domain to bend around persistence, and records cannot be entities anyway.
Only JpaPaymentDAO knows persistence is JPA. PaymentJpaRepository is
package-private, so the service layer depends on the PaymentDAO interface and
nothing else. Swapping the implementation would not touch a line of business
logic.
Flyway owns the schema; Hibernate validates against it. ddl-auto=validate
means an entity that has drifted from the migrations fails at startup rather
than at the first query. Letting Hibernate generate the schema would make the
migrations decorative. This caught a real mismatch during development: the
migration declared CHAR(3) while the entity mapped varchar(3).
open-in-view is disabled. The default leaves the persistence context open
for the whole request, which hides N+1 queries behind lazy loading in the view
layer. Off, they surface during development.
Constructor injection over field injection. The dependency is final, the
object is never observed half-built, and the class can be instantiated directly
in a test with a mock, needing no Spring context.
Validation is duplicated on purpose. Bean Validation rejects malformed
requests at the HTTP edge with a useful message, and the service re-checks the
same rules so it stays correct when called from anywhere else, as DemoRunner
does.
A malformed id is a miss, not a crash. GET /api/payments/banana returns
404 rather than 500, because JpaPaymentDAO treats an unparseable UUID as
"not found".
Amounts are BigDecimal, stored as NUMERIC(19,2). Binary floating point
cannot represent most decimal fractions exactly, which is the wrong tradeoff for
money.
29 tests across four classes, layered so each runs at the cheapest level that can still catch its failure:
| Class | Scope | Needs Docker |
|---|---|---|
PaymentServiceImplTest |
Business rules, DAO mocked, no Spring context | no |
PaymentControllerTest |
@WebMvcTest slice: routing, JSON, validation, status codes |
no |
JpaPaymentDAOTest |
Real Postgres via Testcontainers: schema, mapping, round trips | yes |
CoreApplicationTests |
Full context against a real database | yes |
The database tests run against PostgreSQL 16 with the Flyway migrations applied, not an embedded substitute. H2 would happily accept a schema that Postgres rejects, which defeats the purpose of testing the mapping at all.
Tests run: 29, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
JaCoCo reports 75% instruction / 88% branch coverage to
target/site/jacoco/index.html. The business classes sit between 88% and 100%.
The overall figure is held down by DemoRunner, which only runs under the
demo profile, and by CoreApplication.main.
Boot 4 modularized heavily, which is worth knowing if you are reading the
pom.xml:
- Test slices moved out of
spring-boot-test-autoconfigureinto per-technology modules, so@WebMvcTestneedsspring-boot-webmvc-testand@DataJpaTestneedsspring-boot-data-jpa-test. - Flyway's auto-configuration moved to
spring-boot-flyway. Without that module,flyway-coresits on the classpath but never runs. - Testcontainers versions are no longer managed, so its BOM is imported explicitly. Version 2.x is required to talk to Docker Engine 29.
- Surefire's
argLinestarts with@{argLine}to carry JaCoCo's agent through. Omitting it silently produces a zero-coverage report.