Skip to main content

Domain Model

The domain model is the vocabulary of the application — it names the things that matter (Applicant, Loan, Offer) and the rules that govern them (RiskBand, RejectionReason).

What Problem Does It Solve?​

Without a well-defined domain model, business logic leaks into controllers, validation is scattered, and terms like "risk" mean different things in different places. A coherent domain model makes the code read like the business it models.

The Three Layers of the Domain​

The project separates its domain objects into three layers with distinct responsibilities:

DTOs carry data across the HTTP boundary. Entities own persistence. Enums encode the business rules as type-safe constants.

DTOs — Java Records​

All DTOs are Java records — immutable data carriers introduced in Java 16.

LoanApplicationRequest​

The top-level input. It composes ApplicantDTO and LoanDTO and cascades Bean Validation to both with @Valid.

public record LoanApplicationRequest(
@NotNull(message = "Applicant details are required")
@Valid
ApplicantDTO applicant, // ← @Valid triggers validation on the nested record

@NotNull(message = "Loan details are required")
@Valid
LoanDTO loan
){}

ApplicantDTO​

Captures borrower information with constraints that enforce the bank's acceptance criteria at the API layer.

public record ApplicantDTO(
@NotBlank
@Pattern(regexp = "^[a-zA-Z ]+$", message = "Only letters and spaces allowed")
String name,

@NotNull @Min(21) @Max(60)
Integer age, // ← age range enforced before business logic runs

@NotNull @DecimalMin("0.01")
BigDecimal monthlyIncome,

@NotNull
EmploymentType employmentType, // ← enum; invalid string → 400 automatically

@NotNull @Min(300) @Max(900)
Integer creditScore
){}

LoanDTO​

Loan request parameters with range validation that prevents unreasonable inputs from ever reaching the service.

public record LoanDTO(
@NotNull @DecimalMin("10000") @DecimalMax("5000000")
BigDecimal amount,

@NotNull @Min(6) @Max(360)
Integer tenureMonths, // ← 6 months to 30 years

@NotNull
LoanPurpose purpose
){}

LoanApplicationResponse​

The return value. Uses @JsonInclude(NON_NULL) so the JSON doesn't show "offer": null for rejections or "rejectionReasons": null for approvals.

public record LoanApplicationResponse(
String applicationId,
ApplicationStatus status,
RiskBand riskBand,

@JsonInclude(JsonInclude.Include.NON_NULL)
OfferDTO offer, // ← absent from rejection responses

@JsonInclude(JsonInclude.Include.NON_NULL)
List<RejectionReason> rejectionReasons // ← absent from approval responses
){}

JPA Entities — The Persistence Side​

LoanApplication — The Aggregate Root​

The single @Entity in the project. Rather than creating separate tables for Applicant, Loan, and Offer, this project uses @Embeddable value objects — all data lands in one table (loan_applications).

@Entity
@Table(name = "loan_applications")
public class LoanApplication {

@Id
private String applicationId; // ← UUID string, generated in @PrePersist

@Embedded
private Applicant applicant; // ← columns merge into loan_applications table

@Embedded
@AttributeOverrides({ // ← rename columns to avoid clashes
@AttributeOverride(name = "amount", column = @Column(name = "loan_amount")),
@AttributeOverride(name = "tenureMonths", column = @Column(name = "loan_tenure_months")),
@AttributeOverride(name = "purpose", column = @Column(name = "loan_purpose"))
})
private Loan loan;

@Embedded
@AttributeOverrides({
@AttributeOverride(name = "interestRate", column = @Column(name = "offer_interest_rate")),
@AttributeOverride(name = "tenureMonths", column = @Column(name = "offer_tenure_months")),
@AttributeOverride(name = "emi", column = @Column(name = "offer_emi")),
@AttributeOverride(name = "totalPayable", column = @Column(name = "offer_total_payable"))
})
private Offer offer; // ← nullable for rejected applications

@Enumerated(EnumType.STRING)
private ApplicationStatus status; // ← APPROVED or REJECTED stored as string

@Enumerated(EnumType.STRING)
private RiskBand riskBand; // ← null for rejected applications

@ElementCollection
@CollectionTable(name = "rejection_reasons",
joinColumns = @JoinColumn(name = "application_id"))
@Enumerated(EnumType.STRING)
@Column(name = "reason")
private List<RejectionReason> rejectionReasons; // ← stored in a separate join table
@AttributeOverrides — Why?

Loan has a tenureMonths field, and so does Offer. If both are embedded in the same table, JPA would try to create two tenure_months columns — a naming clash. @AttributeOverride renames them to loan_tenure_months and offer_tenure_months.

UUID Generation with @PrePersist​

@PrePersist
public void generateId() {
if (this.applicationId == null) {
this.applicationId = UUID.randomUUID().toString(); // ← generates before first INSERT
}
if (this.rejectionReasons == null) {
this.rejectionReasons = new ArrayList<>(); // ← prevents NPE on save
}
}

The ID is a String (UUID) rather than a Long auto-increment. This is common in distributed systems where IDs must be globally unique across nodes.

Applicant, Loan, Offer — @Embeddable Value Objects​

These are JPA value objects: they have no @Id, exist only inside LoanApplication, and share its lifecycle. The fields of each are flattened into the parent entity's table.

@Embeddable
public class Applicant {
private String name;
private Integer age;
private BigDecimal monthlyIncome;
@Enumerated(EnumType.STRING)
private EmploymentType employmentType;
private Integer creditScore;
// constructors, getters only — no setters
}

The Five Enums​

Enums encode business rules that would otherwise be magic strings scattered in if statements.

EnumValuesRole
ApplicationStatusAPPROVED, REJECTEDFinal decision on the application
RiskBandLOW, MEDIUM, HIGHDrives interest rate premium
EmploymentTypeSALARIED, SELF_EMPLOYEDAdds an employment premium to the rate
LoanPurposePERSONAL, HOME, VEHICLE, EDUCATION, BUSINESSRecorded but not used in rate calculation (extensible)
RejectionReasonCREDIT_SCORE_TOO_LOW, AGE_TENURE_LIMIT_EXCEEDED, EMI_EXCEEDS_60_PERCENT, EMI_EXCEEDS_50_PERCENTTells the applicant exactly why they were rejected

Why Records for DTOs, Not Lombok?​

FeatureJava RecordLombok @Data
ImmutabilityYes — all fields are finalRequires @Value
BoilerplateZeroNear-zero with annotation
External dependencyNoneLombok jar required
JDK supportBuilt-in since Java 16Annotation processor
Reflection-safetyGoodOccasional issues with some frameworks

Records win when you want immutable data carriers with no dependencies. The project consistently uses records for DTOs and embeddable classes for persistence.

Common Pitfalls​

  • Forgetting @AttributeOverride on embedded types with shared field names — JPA will fail with a "Repeated column" exception at startup.
  • Using float/double for monthlyIncome or amount — BigDecimal is mandatory for money fields.
  • Using EnumType.ORDINAL — always use EnumType.STRING; ordinal breaks if you ever reorder enum constants.
  • Mutable embedded objects — @Embeddable classes should be treated as value objects (effectively immutable once set).

Interview Questions​

Q: What is the difference between @Embeddable and @Entity?
A: An @Entity has its own identity (@Id) and lives in its own table. An @Embeddable has no identity and is stored inline in its owning entity's table. Use @Embeddable for value objects whose lifecycle is controlled entirely by the parent.

Q: Why use @AttributeOverride?
A: When two @Embeddable types embedded in the same @Entity share a field name (like tenureMonths), JPA would try to create duplicate columns. @AttributeOverride maps each field to a unique column name.

Q: Why use @JsonInclude(NON_NULL) on a record?
A: To prevent null fields from appearing in the JSON response. An approved application has no rejectionReasons, and a rejected one has no offer — without NON_NULL, the JSON would include "offer": null which is confusing for API consumers.

Further Reading​