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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Added
- Phase 2A Entity Model Hardening & Rich Domain:
- Replaced `Double` primitives with `BigDecimal` (`precision = 19, scale = 4`) across `User` and `Transaction` entities.
- Implemented Rich Domain methods (`User.debit()`, `User.credit()`) encapsulating balance invariants and state validation.
- Added audit timestamps (`createdAt`, `updatedAt`) and optimistic locking (`@Version version`) support to `User`.
- Added `TransactionStatus` (`INITIATED`, `COMPLETED`, `FAILED`, `REFUNDED`) and `TransactionType` (`TRANSFER`, `REFUND`) enums.
- Added JPA `@ManyToOne` foreign key relationships between `Transaction` and `User` entities with denormalized UPI strings.
- Added UUID `referenceId` auto-generation (`@PrePersist`) on `Transaction`.
- Refactored all services (`UserService`, `TransactionService`) and controllers (`UserController`, `TransactionController`) to use constructor injection.
- Unit test suite for `User` domain logic and `Transaction` reference ID auto-generation (`UserTest`, `TransactionTest`).
- Architectural Decision Records: `ADR-001` (BigDecimal), `ADR-002` (Constructor injection), `ADR-003` (Rich Domain Model).
- Project scaffolding: MIT `LICENSE`, `CHANGELOG.md`, `.editorconfig`.
- Architectural Decision Records log (`docs/ADR.md`) and engineering standards guide (`docs/CONVENTIONS.md`).
- Spotless code formatting plugin (`com.diffplug.spotless:spotless-maven-plugin`) integrated into Maven build.
Expand Down
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,18 @@ The project is evolving through a phased implementation roadmap. See the full pl
|-------|-------------|--------|
| **Phase 0** | Baseline REST API — User & Transaction CRUD with H2 in-memory storage | ✅ Complete |
| **Phase 1** | Project hygiene — Spotless, Checkstyle, GitHub Actions CI | ✅ Complete |
| **Phase 2** | Domain model hardening, DTO layer, validation, error handling | ⬜ Not Started |
| **Phase 3+** | Concurrency control, Flyway, security, observability, and more | ⬜ Not Started |
| **Phase 2A** | Domain model hardening — `BigDecimal` financials, rich domain methods, audit timestamps | ✅ Complete |
| **Phase 2B+** | DTO layer, validation, error handling, concurrency control, security, observability | 🔄 In Progress |

---

## Implemented Features

- **REST API**: Basic CRUD endpoints for User registration and Transaction creation.
- **Rich Domain Model**: Entities encapsulate domain invariants (`User.debit()`, `User.credit()`) and balance validation.
- **Financial Precision**: All monetary values mapped using `BigDecimal` (`precision = 19, scale = 4`) to prevent floating-point rounding errors.
- **Entity Hardening**: Audit timestamps (`createdAt`, `updatedAt`), optimistic locking (`@Version`), JPA `@ManyToOne` foreign key constraints, UUID reference IDs, and transaction status/type enums.
- **Constructor Injection**: Enforced across all service and controller components for immutability and testability.
- **REST API**: CRUD endpoints for User registration and Transaction creation.
- **Layered Architecture**: Controller → Service → Repository pattern with Spring Data JPA.
- **In-Memory Database**: H2 with auto-generated schema for zero-dependency local development.
- **Code Quality**: Spotless (Eclipse formatter) + Checkstyle enforced at Maven `validate` phase.
Expand Down
84 changes: 67 additions & 17 deletions docs/ADR.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,36 +10,86 @@ ADRs document significant technical decisions, along with their context, rationa

| ADR # | Title | Date | Status |
| :--- | :--- | :--- | :--- |
| *No ADRs recorded yet. ADRs will be appended as features are implemented.* | | | |
| [ADR-001](#adr-001-use-bigdecimal-for-all-monetary-values) | Use BigDecimal for all monetary values | 2026-08-01 | Accepted |
| [ADR-002](#adr-002-constructor-injection-over-field-injection) | Constructor injection over field injection | 2026-08-01 | Accepted |
| [ADR-003](#adr-003-rich-domain-model-over-anemic-domain-model) | Rich Domain Model over Anemic Domain Model | 2026-08-01 | Accepted |

---

## ADR Template
### ADR-001: Use BigDecimal for all monetary values

When creating a new Architecture Decision Record, use the following template:
**Date**: 2026-08-01
**Status**: Accepted
**Phase**: Phase 2A

```markdown
### ADR-XXX: [Short Title of Decision]
#### Context & Problem Statement
Floating-point primitive types (`double`, `float`) use IEEE 754 binary representation, which cannot precisely represent base-10 decimals (e.g., `0.1 + 0.2 = 0.30000000000000004`). In financial applications, accumulated rounding errors compromise ledger integrity and result in monetary discrepancy.

#### Considered Options
1. **IEEE 754 Floating-Point (`Double`/`float`)**: Fast, built-in, but causes imprecise rounding error.
2. **Integer Cent/Sub-unit Amounts (`Long` cents)**: Precise, but awkward when dealing with fractional currency fractions or dynamic currency precision.
3. **Java `BigDecimal`**: Arbitrary-precision signed decimal numbers, explicitly suited for financial calculations.

#### Decision Outcome
Chosen Option: **Java `BigDecimal`**

##### Rationale
`BigDecimal` provides exact precision representation for decimal currency values with configurable scale (`precision = 19, scale = 4` in database mapping) and explicit rounding modes (`RoundingMode.HALF_EVEN`).

#### Consequences
- **Positive**: Zero floating-point rounding errors in monetary balance arithmetic and ledger entries.
- **Negative / Trade-offs**: Slightly higher memory overhead and minor performance cost compared to native double primitives.
- **Risks & Mitigations**: Always specify scale and explicit rounding mode (`RoundingMode.HALF_EVEN`) when performing division or scale adjustments.

---

### ADR-002: Constructor injection over field injection

**Date**: 2026-08-01
**Status**: Accepted
**Phase**: Phase 2A

#### Context & Problem Statement
Field injection via `@Autowired` tightly couples Spring components to the Spring DI container, prevents `final` immutable fields, hides component dependencies, and makes unit testing difficult without starting Spring contexts or using reflection.

#### Considered Options
1. **Field Injection (`@Autowired private Service service`)**: Convenient syntax, but hides dependencies and inhibits immutability/testability.
2. **Setter Injection**: Allows optional dependencies, but enables mutable component state post-construction.
3. **Constructor Injection**: Explicitly declares required dependencies as `final` parameters.

#### Decision Outcome
Chosen Option: **Constructor Injection**

##### Rationale
Constructor injection enforces immutability (`final` fields), guarantees all required dependencies are provided at instantiation time, simplifies unit testing without Spring context spinners, and aligns with Spring Framework best practices.

#### Consequences
- **Positive**: Clean component testability, immutability guarantees, static dependency verification at compile time.
- **Negative / Trade-offs**: Slightly more boilerplate constructor code (mitigated by explicit standard constructors).
- **Risks & Mitigations**: Circular dependency detection occurs at startup (which is desirable as it indicates architectural smell).

---

### ADR-003: Rich Domain Model over Anemic Domain Model

**Date**: YYYY-MM-DD
**Status**: [Proposed | Accepted | Superseded | Deprecated]
**Phase**: [Phase X]
**Date**: 2026-08-01
**Status**: Accepted
**Phase**: Phase 2A

#### Context & Problem Statement
What is the technical problem we are solving? What constraints or requirements impact this decision?
Anemic domain models treat JPA entities as simple data bags with getters and setters, scattering business invariants (such as non-negative balance checks and debit rules) across multiple service classes.

#### Considered Options
1. **Option 1**: Description
2. **Option 2**: Description
1. **Anemic Domain Model**: Entities hold only state; services perform all business logic and validations.
2. **Rich Domain Model**: Entities encapsulate state alongside domain behaviors and invariant checks (`debit()`, `credit()`).

#### Decision Outcome
Chosen Option: **[Option Name]**
Chosen Option: **Rich Domain Model**

##### Rationale
Why was this option chosen over the alternatives?
Rich domain entities encapsulate domain invariants directly within entity boundaries (`User.debit()`, `User.credit()`), preventing invalid domain state transitions (e.g. negative balances or invalid debit amounts) regardless of caller invocation path.

#### Consequences
- **Positive**: What benefits do we gain?
- **Negative / Trade-offs**: What challenges, overhead, or limitations are introduced?
- **Risks & Mitigations**: How do we mitigate negative impacts?
```
- **Positive**: High cohesion, self-validating entities, reusable business logic across multiple services.
- **Negative / Trade-offs**: Entities must remain decoupled from infrastructure concerns (repositories, external APIs).
- **Risks & Mitigations**: Keep entity methods focused strictly on state invariants; delegate orchestration to domain services.
21 changes: 21 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,3 +266,24 @@ Integration tests verify the full lifecycle of a transaction across actual conta
3. **Asynchronous Outbox Publisher Testing**:
- Confirms the outbox poller successfully dispatches records to Kafka.
- **Assertion**: Write an outbox record, wait for the scheduled poller execution, read the event from the Testcontainers Kafka consumer, and verify the message matches the expected transaction schema.

---

## 13. Entity Model & Rich Domain Architecture

To establish robust domain boundaries and prevent corrupt data state, the entity layer adheres to Rich Domain Model principles and precise database constraints:

### A. Financial Precision (`BigDecimal`)
All monetary columns (`balance`, `amount`) are represented using `BigDecimal` mapped to database column definition `@Column(precision = 19, scale = 4, nullable = false)`. Floating-point binary arithmetic primitives (`double`, `float`) are prohibited to eliminate representation error accumulation.

### B. Rich Domain Invariants
Entities encapsulate their own business invariants and state transitions:
- **`User.debit(BigDecimal amount)`**: Enforces positive debit amounts and verifies balance adequacy (`balance >= amount`). Throws `IllegalStateException` or domain exceptions if invariants fail.
- **`User.credit(BigDecimal amount)`**: Enforces positive credit amounts and updates account balance atomically in-memory.

### C. Auditability & Optimistic Locking
- **Audit Timestamps**: `@CreationTimestamp Instant createdAt` and `@UpdateTimestamp Instant updatedAt` automatically track record creation and updates.
- **Optimistic Locking**: `@Version Long version` enables Hibernate to prevent lost updates during concurrent non-locking state updates.

### D. Relational Foreign Key Integrity
The `Transaction` entity maintains explicit JPA `@ManyToOne(fetch = FetchType.LAZY)` foreign key relationships to `User` for `sender` and `receiver`, while retaining denormalized `senderUpiId` and `receiverUpiId` fields for index-optimized queries.
4 changes: 2 additions & 2 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,9 @@ Dockerfile JDK alignment, GraalVM profile fix, README cleanup.

---

## Phase 2 — Domain Model & API Hardening
## Phase 2 — Domain Model & API Hardening 🔄

### 2A — Entity Model & Rich Domain
### 2A — Entity Model & Rich Domain
`BigDecimal` financials, `@Column` constraints, JPA FK relationships, audit timestamps, status/type enums, rich domain methods (`debit`/`credit`), constructor injection.

### 2B — DTO Layer & Validation ⬜
Expand Down
15 changes: 14 additions & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
<scope>provided</scope>
</dependency>

<dependency>
Expand All @@ -66,6 +66,19 @@

<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>

<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package com.payflow.controller;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
Expand All @@ -13,8 +12,11 @@
@RequestMapping("/transactions")
public class TransactionController {

@Autowired
private TransactionService transactionService;
private final TransactionService transactionService;

public TransactionController(TransactionService transactionService) {
this.transactionService = transactionService;
}

@PostMapping
public Transaction sendMoney(@RequestBody Transaction transaction) {
Expand Down
11 changes: 7 additions & 4 deletions src/main/java/com/payflow/controller/UserController.java
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
package com.payflow.controller;

import java.math.BigDecimal;
import java.util.List;
import java.util.Optional;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
Expand All @@ -18,8 +18,11 @@
@RequestMapping("/users")
public class UserController {

@Autowired
private UserService userService;
private final UserService userService;

public UserController(UserService userService) {
this.userService = userService;
}

@PostMapping
public User registerUser(@RequestBody User user) {
Expand All @@ -42,7 +45,7 @@ public Optional<User> getUserByUpiId(@PathVariable String upiId) {
}

@GetMapping("/balance/{amount}")
public List<User> getUsersWithBalanceAbove(@PathVariable Double amount) {
public List<User> getUsersWithBalanceAbove(@PathVariable BigDecimal amount) {
return userService.getUsersWithBalanceAbove(amount);
}
}
92 changes: 53 additions & 39 deletions src/main/java/com/payflow/entity/Transaction.java
Original file line number Diff line number Diff line change
@@ -1,67 +1,81 @@
package com.payflow.entity;

import java.math.BigDecimal;
import java.time.Instant;
import java.util.UUID;

import org.hibernate.annotations.CreationTimestamp;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Entity
@Table(name = "transactions")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Transaction {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long transactionId;

private String senderUpiId;

private String receiverUpiId;

private Double amount;
@Column(nullable = false, unique = true, updatable = false)
private UUID referenceId;

private String note;

public Transaction() {
}

public Long getTransactionId() {
return transactionId;
}
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "sender_id")
private User sender;

public void setTransactionId(Long transactionId) {
this.transactionId = transactionId;
}
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "receiver_id")
private User receiver;

public String getSenderUpiId() {
return senderUpiId;
}
@Column(nullable = false, length = 30)
private String senderUpiId;

public void setSenderUpiId(String senderUpiId) {
this.senderUpiId = senderUpiId;
}
@Column(nullable = false, length = 30)
private String receiverUpiId;

public String getReceiverUpiId() {
return receiverUpiId;
}
@Column(precision = 19, scale = 4, nullable = false)
private BigDecimal amount;

public void setReceiverUpiId(String receiverUpiId) {
this.receiverUpiId = receiverUpiId;
}
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private TransactionStatus status;

public Double getAmount() {
return amount;
}
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private TransactionType type;

public void setAmount(Double amount) {
this.amount = amount;
}
@Column(length = 255)
private String note;

public String getNote() {
return note;
}
@CreationTimestamp
@Column(nullable = false, updatable = false)
private Instant createdAt;

public void setNote(String note) {
this.note = note;
@PrePersist
public void ensureReferenceId() {
if (this.referenceId == null) {
this.referenceId = UUID.randomUUID();
}
}
}
5 changes: 5 additions & 0 deletions src/main/java/com/payflow/entity/TransactionStatus.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.payflow.entity;

public enum TransactionStatus {
INITIATED, COMPLETED, FAILED, REFUNDED
}
5 changes: 5 additions & 0 deletions src/main/java/com/payflow/entity/TransactionType.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.payflow.entity;

public enum TransactionType {
TRANSFER, REFUND
}
Loading
Loading