Skip to main content

Arsitektur Backend Apache Fineract

Ringkasan Eksekutif

Backend Apache Fineract dibangun dengan arsitektur modular monolith menggunakan Spring Boot yang memungkinkan desenvolvimento, testing, dan deployment yang independen untuk setiap modul. Arsitektur ini mengimplementasikan prinsip Clean Architecture dengan pemisahan yang jelas antara business logic, data access, dan presentation layers.

Arsitektur Modular Monolith

Struktur Modul Inti

Apache Fineract menggunakan pendekatan modular monolith di mana sistem dibagi menjadi modul-modul yang independently testable dan deployable, namun masih dalam satu artifacts deployment.

Dependency Management

// build.gradle (Root project)
allprojects {
apply plugin: 'java'
apply plugin: 'io.spring.dependency-management'

group = 'org.apache.fineract'
version = '1.9.0-SNAPSHOT'

repositories {
mavenCentral()
maven { url 'https://repo.spring.io/milestone' }
}

dependencyManagement {
imports {
mavenBom 'org.springframework.boot:spring-boot-dependencies:3.1.5'
}

dependencies {
dependency 'org.springframework.boot:spring-boot-starter:3.1.5'
dependency 'org.springframework.boot:spring-boot-starter-web:3.1.5'
dependency 'org.springframework.boot:spring-boot-starter-data-jpa:3.1.5'
dependency 'org.springframework.boot:spring-boot-starter-security:3.1.5'
dependency 'org.springframework.boot:spring-boot-starter-batch:3.1.5'
}
}
}

// Module-specific dependencies (fineract-loan)
dependencies {
implementation project(':fineract-core')
implementation project(':fineract-validation')

implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'com.fasterxml.jackson.core:jackson-databind'

testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
}

Layered Architecture

1. Controller Layer (REST APIs)

@RestController
@RequestMapping("/api/v1/loans")
@Api(value = "Loan Resource", description = "All operations related to loans")
public class LoansApiResource {

private final LoanWritePlatformService loanWritePlatformService;
private final LoanReadPlatformService loanReadPlatformService;
private final LoanCommandProvider loanCommandProvider;

@Autowired
public LoansApiResource(LoanWritePlatformService loanWritePlatformService,
LoanReadPlatformService loanReadPlatformService,
LoanCommandProvider loanCommandProvider) {
this.loanWritePlatformService = loanWritePlatformService;
this.loanReadPlatformService = loanReadPlatformService;
this.loanCommandProvider = loanCommandProvider;
}

@GetMapping
@PreAuthorize("hasAuthority('READ_WRITE_FINERACT_API')")
public String retrieveAll(@Context UriInfo uriInfo) {
Collection<LoanAccountData> loans = this.loanReadPlatformService.retrieveAll(uriInfo.getQueryParameters());
return this.toApiJsonSerializer.serialize(loans);
}

@GetMapping("/{loanId}")
@PreAuthorize("hasAuthority('READ_WRITE_FINERACT_API')")
public String retrieveOne(@PathParam("loanId") Long loanId, @Context UriInfo uriInfo) {
LoanAccountData loan = this.loanReadPlatformService.retrieveOne(loanId);
return this.toApiJsonSerializer.serialize(loan);
}

@PostMapping
@PreAuthorize("hasAuthority('READ_WRITE_FINERACT_API')")
public String create(@RequestBody String apiRequestBodyAsJson) {
JsonElement parsedRequest = this.fromApiJsonHelper.parse(apiRequestBodyAsJson);

CreateLoanApplicationRequest request = this.fromApiJsonHelper.fromJson(parsedRequest, CreateLoanApplicationRequest.class);

CommandProcessingResult result = this.loanWritePlatformService.submitApplication(request);

return this.toApiJsonSerializer.serialize(result);
}

@PostMapping("/{loanId}")
@PreAuthorize("hasAuthority('READ_WRITE_FINERACT_API')")
public String stateTransitions(@PathParam("loanId") Long loanId,
@QueryParam("command") String commandParam,
@RequestBody String apiRequestBodyAsJson) {
final CommandWrapper commandRequest = this.loanCommandProvider.newCommandWrapper()
.withCommandId(loanId)
.withCommand(commandParam)
.withJson(apiRequestBodyAsJson);

final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest);

return this.toApiJsonSerializer.serialize(result);
}
}

2. Service Layer (Business Logic)

Command Service Implementation:

@Service
@Transactional
public class LoanWritePlatformService {

private final LoanRepository loanRepository;
private final LoanProductRepository loanProductRepository;
private final ClientRepository clientRepository;
private final CommandSourceProcessor commandSourceProcessor;
private final LoanEventWriter loanEventWriter;
private final InterestCalculationService interestCalculationService;
private final LoanUtilizationDomainService loanUtilizationDomainService;

public CommandProcessingResult submitApplication(CreateLoanApplicationRequest request) {
try {
return this.commandSourceProcessor.processCommand(() -> {
// Business validation
Client client = this.clientRepository.findById(request.getClientId())
.orElseThrow(() -> new ClientNotFoundException(request.getClientId()));

LoanProduct loanProduct = this.loanProductRepository.findById(request.getProductId())
.orElseThrow(() -> new LoanProductNotFoundException(request.getProductId()));

// Create loan entity
Loan loan = Loan.createNew(client, loanProduct, request);

// Validate business rules
validateLoanCanBeCreated(loan);

// Calculate initial schedule if needed
if (request.isCreateSchedule()) {
calculateAndSetRepaymentSchedule(loan);
}

// Save loan
loan = this.loanRepository.save(loan);

// Write events
this.loanEventWriter.writeLoanCreatedEvent(loan);

// Audit trail
this.auditColumnValue.writeAuditLog(this.getClass().getSimpleName(),
"createLoanApplication", request, 0L);

return CommandProcessingResult.of(request.getClientId(), loan.getId());
});
} catch (DataIntegrityViolationException e) {
return CommandProcessingResult.of(request.getClientId(),
CommandProcessingResult.RESOURCE_ID_TYPES_INVALID);
}
}

public CommandProcessingResult approveLoan(Long loanId, ApproveLoanRequest request) {
return this.commandSourceProcessor.processCommand(() -> {
Loan loan = this.loanRepository.findById(loanId)
.orElseThrow(() -> new LoanNotFoundException(loanId));

// Validate loan status
if (!loan.status().isSubmittedAndPendingApproval()) {
throw new LoanNotInSubmittedStateException(loanId);
}

// Business validations
validateLoanCanBeApproved(loan);

// Approve loan
loan.approve(request.getApprovedBy(), request.getApprovedDate(), request.getApprovedPrincipal());

// Update in database
loan = this.loanRepository.save(loan);

// Write events
this.loanEventWriter.writeLoanApprovedEvent(loan);

return CommandProcessingResult.of(loanId, loan);
});
}

public CommandProcessingResult disburseLoan(Long loanId, DisburseLoanRequest request) {
return this.commandSourceProcessor.processCommand(() -> {
Loan loan = this.loanRepository.findById(loanId)
.orElseThrow(() -> new LoanNotFoundException(loanId));

// Validate loan status
if (!loan.status().isApproved()) {
throw new LoanNotApprovedException(loanId);
}

// Business validations
validateLoanCanBeDisbursed(loan);

// Process disbursement
MonetaryAmount disbursementAmount = request.getTransactionAmount();
LocalDate disbursementDate = request.getActualDisbursementDate();

loan.disburse(disbursementDate, disbursementAmount, request.getPaymentTypeId());

// Save loan
loan = this.loanRepository.save(loan);

// Write events
this.loanEventWriter.writeLoanDisbursedEvent(loan, disbursementAmount);

return CommandProcessingResult.of(loanId, loan);
});
}

private void validateLoanCanBeCreated(Loan loan) {
if (loan.getPrincipal().compareTo(loan.getLoanProduct().getMaxPrincipal()) > 0) {
throw new LoanAmountGreaterThanMaxAllowedException(
loan.getPrincipal(), loan.getLoanProduct().getMaxPrincipal());
}

if (loan.getClient().isLoanClamped()) {
throw new ClientLoanClampedException(loan.getClient().getId());
}

// Additional validations...
}

private void calculateAndSetRepaymentSchedule(Loan loan) {
List<LoanSchedule> schedule = this.interestCalculationService
.generateRepaymentSchedule(loan);

loan.setRepaymentSchedule(schedule);
}
}

Query Service Implementation:

@Service
public class LoanReadPlatformService {

private final JdbcTemplate jdbcTemplate;
private final LoanMapper loanMapper;
private final PaginationHelper paginationHelper;

public Collection<LoanAccountData> retrieveAll(MultiValueMap<String, String> parameters) {
StringBuilder sqlBuilder = new StringBuilder("select ")
.append(loanMapper.retrieveSelectSQL())
.append(" from m_loan l ")
.append(" join m_client c on c.id = l.client_id ")
.append(" join m_office o on o.id = c.office_id ")
.append(" join m_loan_product p on p.id = l.product_id ");

final List<SqlRowSet> result = this.paginationHelper.fetchPage(
this.jdbcTemplate, sqlBuilder.toString(),
loanMapper.getRetrieveSelectSQL(),
parameters, loanMapper.getRetrieveJoinSQL());

return this.loanMapper.mapData(result);
}

public LoanAccountData retrieveOne(Long loanId) {
String sql = "select " + loanMapper.retrieveSelectSQL() +
" from m_loan l " +
" join m_client c on c.id = l.client_id " +
" join m_office o on o.id = c.office_id " +
" join m_loan_product p on p.id = l.product_id " +
" where l.id = ?";

SqlRowSet rs = this.jdbcTemplate.queryForRowSet(sql, loanId);

if (rs.next()) {
return this.loanMapper.mapRow(rs, 0);
} else {
throw new LoanNotFoundException(loanId);
}
}

public LoanAccountData retrieveClientId(Long clientId, Long productId) {
String sql = "select " + loanMapper.retrieveSelectSQL() +
" from m_loan l " +
" join m_loan_product p on p.id = l.product_id " +
" where l.client_id = ? and p.id = ? " +
" and l.is_closed = 0 " +
" order by l.disbursedon_date desc " +
" limit 1";

SqlRowSet rs = this.jdbcTemplate.queryForRowSet(sql, clientId, productId);

if (rs.next()) {
return this.loanMapper.mapRow(rs, 0);
}
return null;
}

public LoanSchedule loanSchedule(Long loanId) {
String sql = "select ls.duedate, ls.installment, ls.principal_amount, ls.interest_amount, " +
"ls.fee_charges, ls.penalty_charges, ls.total_due, ls.total_outstanding " +
"from m_loan_repayment_schedule ls " +
"where ls.loan_id = ? " +
"and ls.completed_derived is false " +
"order by ls.installment";

List<LoanSchedulePeriodData> periods = this.jdbcTemplate.query(sql,
new Object[]{loanId},
new BeanPropertyRowMapper<>(LoanSchedulePeriodData.class));

return LoanSchedule.withRepaymentPeriods(periods);
}
}

3. Repository Layer (Data Access)

JPA Repository Implementation:

@Repository
public interface LoanRepository extends JpaRepository<Loan, Long> {

@Query("SELECT l FROM Loan l WHERE l.client.id = :clientId AND l.status = :status")
List<Loan> findByClientAndStatus(@Param("clientId") Long clientId,
@Param("status") LoanStatus status);

@Query("SELECT l FROM Loan l WHERE l.loanProduct.id = :productId")
List<Loan> findByProductId(@Param("productId") Long productId);

@Query("SELECT l FROM Loan l WHERE l.status = :status AND l.expectedMaturityDate <= :date")
List<Loan> findMatureLoans(@Param("status") LoanStatus status,
@Param("date") LocalDate date);

@Query(value = "SELECT * FROM m_loan WHERE DATEDIFF(NOW(), expected_disbursement_date) > 30",
nativeQuery = true)
List<Loan> findOverdueLoans();

@Modifying
@Query("UPDATE Loan l SET l.status = :newStatus WHERE l.id = :loanId")
void updateLoanStatus(@Param("loanId") Long loanId, @Param("newStatus") LoanStatus newStatus);

@Query("SELECT l FROM Loan l WHERE l.accountNo = :accountNo")
Optional<Loan> findByAccountNumber(@Param("accountNo") String accountNo);

@Query("SELECT l FROM Loan l WHERE l.client.id = :clientId ORDER BY l.createdDate DESC")
List<Loan> findByClientIdOrderByCreatedDateDesc(@Param("clientId") Long clientId);
}

Custom Repository Implementation:

public class LoanRepositoryImpl implements LoanRepositoryCustom {

private final EntityManager entityManager;
private final SessionFactory sessionFactory;

@Autowired
public LoanRepositoryImpl(EntityManager entityManager) {
this.entityManager = entityManager;
this.sessionFactory = entityManager.unwrap(SessionFactory.class);
}

@Override
public List<Loan> findLoansBySearchCriteria(LoanSearchCriteria criteria) {
CriteriaBuilder criteriaBuilder = entityManager.getCriteriaBuilder();
CriteriaQuery<Loan> criteriaQuery = criteriaBuilder.createQuery(Loan.class);
Root<Loan> loanRoot = criteriaQuery.from(Loan.class);

List<Predicate> predicates = new ArrayList<>();

if (criteria.getClientId() != null) {
predicates.add(criteriaBuilder.equal(loanRoot.get("client").get("id"),
criteria.getClientId()));
}

if (criteria.getProductId() != null) {
predicates.add(criteriaBuilder.equal(loanRoot.get("loanProduct").get("id"),
criteria.getProductId()));
}

if (criteria.getStatus() != null) {
predicates.add(criteriaBuilder.equal(loanRoot.get("status"), criteria.getStatus()));
}

if (criteria.getDisbursementFromDate() != null) {
predicates.add(criteriaBuilder.greaterThanOrEqualTo(
loanRoot.get("disbursementDate"), criteria.getDisbursementFromDate()));
}

if (criteria.getDisbursementToDate() != null) {
predicates.add(criteriaBuilder.lessThanOrEqualTo(
loanRoot.get("disbursementDate"), criteria.getDisbursementToDate()));
}

criteriaQuery.select(loanRoot)
.where(predicates.toArray(new Predicate[0]))
.orderBy(criteriaBuilder.desc(loanRoot.get("createdDate")));

TypedQuery<Loan> query = entityManager.createQuery(criteriaQuery);

if (criteria.getOffset() != null) {
query.setFirstResult(criteria.getOffset());
}

if (criteria.getLimit() != null) {
query.setMaxResults(criteria.getLimit());
}

return query.getResultList();
}

@Override
public BigDecimal calculateOutstandingPrincipalForClient(Long clientId) {
String jpql = "SELECT COALESCE(SUM(l.principalOutstanding), 0) " +
"FROM Loan l " +
"WHERE l.client.id = :clientId " +
"AND l.status IN :activeStatuses";

List<String> activeStatuses = Arrays.asList(
"active", "overpaid", "closed_rescheduled", "closed_writtenoff");

TypedQuery<BigDecimal> query = entityManager.createQuery(jpql, BigDecimal.class);
query.setParameter("clientId", clientId);
query.setParameter("activeStatuses", activeStatuses);

return query.getSingleResult();
}

@Override
public void updateLoanInterestCalculation(Long loanId, BigDecimal interestAmount,
LocalDate calculationDate) {
Query query = entityManager.createNativeQuery(
"UPDATE m_loan SET interest_calculated = 1, " +
"interest_calculated_date = ?, " +
"interest_calculated_amount = interest_calculated_amount + ? " +
"WHERE id = ?");

query.setParameter(1, calculationDate);
query.setParameter(2, interestAmount);
query.setParameter(3, loanId);

query.executeUpdate();
}
}

4. Domain Layer (Business Entities)

Loan Entity Implementation:

@Entity
@Table(name = "m_loan")
public class Loan {

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

@Column(name = "account_no", unique = true, nullable = false, length = 20)
private String accountNumber;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "client_id")
private Client client;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "product_id")
private LoanProduct loanProduct;

@Column(name = "principal_amount", precision = 19, scale = 6)
private BigDecimal principal;

@Column(name = "outstanding_principal_amount", precision = 19, scale = 6)
private BigDecimal principalOutstanding;

@Column(name = "status_enum")
private LoanStatus status;

@Column(name = "submittedon_date")
private LocalDate submittedOnDate;

@Column(name = "rejectedon_date")
private LocalDate rejectedOnDate;

@Column(name = "approvedon_date")
private LocalDate approvedOnDate;

@Column(name = "expected_disbursement_date")
private LocalDate expectedDisbursementDate;

@Column(name = "actual_disbursement_date")
private LocalDate actualDisbursementDate;

@OneToMany(mappedBy = "loan", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private List<LoanTransaction> transactions = new ArrayList<>();

@OneToMany(mappedBy = "loan", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private List<LoanRepaymentSchedule> repaymentSchedule = new ArrayList<>();

@Embedded
private LoanInterestRecalculationDetails interestRecalculationDetails;

// Constructors
protected Loan() {}

public static Loan createNew(Client client, LoanProduct loanProduct, CreateLoanApplicationRequest request) {
Loan loan = new Loan();
loan.client = client;
loan.loanProduct = loanProduct;
loan.principal = request.getPrincipal();
loan.principalOutstanding = request.getPrincipal();
loan.status = LoanStatus.SUBMITTED;
loan.submittedOnDate = LocalDate.now();
loan.expectedDisbursementDate = request.getExpectedDisbursementDate();
loan.accountNumber = generateAccountNumber(client, loanProduct);

return loan;
}

// Business Methods
public void approve(User approvedBy, LocalDate approvedDate, BigDecimal approvedPrincipal) {
this.status = LoanStatus.APPROVED;
this.approvedOnDate = approvedDate;
this.approvedBy = approvedBy;
this.principal = approvedPrincipal;
this.principalOutstanding = approvedPrincipal;
this.expectedDisbursementDate = approvedDate;
}

public void disburse(LocalDate disbursementDate, MonetaryAmount disbursementAmount,
String paymentTypeId) {
if (!this.status.isApproved()) {
throw new IllegalStateException("Loan is not approved for disbursement");
}

this.status = LoanStatus.ACTIVE;
this.actualDisbursementDate = disbursementDate;

// Create disbursement transaction
LoanTransaction disbursementTransaction = LoanTransaction.disbursement(
this, disbursementAmount, disbursementDate, paymentTypeId);

this.transactions.add(disbursementTransaction);

// Update outstanding principal
this.principalOutstanding = this.principalOutstanding.subtract(disbursementAmount.getAmount());
}

public void applyPayment(MonetaryAmount paymentAmount, LocalDate paymentDate,
String paymentTypeId, MoneyType moneyType) {
if (!this.status.isActive()) {
throw new IllegalStateException("Cannot apply payment to inactive loan");
}

// Calculate interest portion
BigDecimal interestDue = calculateInterestDue(paymentDate);
BigDecimal principalPayment = paymentAmount.getAmount().subtract(interestDue);

// Create payment transaction
LoanTransaction paymentTransaction = LoanTransaction.repayment(
this, paymentAmount, paymentDate, paymentTypeId, moneyType);

this.transactions.add(paymentTransaction);

// Update outstanding balance
this.principalOutstanding = this.principalOutstanding.subtract(principalPayment);

// Check if loan is fully repaid
if (this.principalOutstanding.compareTo(BigDecimal.ZERO) <= 0) {
this.status = LoanStatus.CLOSED_OBLIGATIONS_MET;
}
}

public BigDecimal calculateInterestDue(LocalDate calculationDate) {
if (!this.status.isActive()) {
return BigDecimal.ZERO;
}

// Get last transaction date
LocalDate lastTransactionDate = getLastTransactionDate();
if (lastTransactionDate == null) {
lastTransactionDate = this.actualDisbursementDate;
}

// Calculate days difference
long daysInArrears = ChronoUnit.DAYS.between(lastTransactionDate, calculationDate);

// Calculate interest using loan product interest calculation method
return this.loanProduct.getInterestCalculationService()
.calculateInterest(this, lastTransactionDate, calculationDate, daysInArrears);
}

public boolean isOverpaid() {
return this.principalOutstanding.compareTo(BigDecimal.ZERO) < 0;
}

public boolean isInArrears(LocalDate comparisonDate) {
if (!this.status.isActive()) {
return false;
}

return getDaysInArrears(comparisonDate) > this.loanProduct.getGraceOnOverduePayments();
}

private LocalDate getLastTransactionDate() {
return this.transactions.stream()
.filter(transaction -> transaction.isReversed() == false)
.map(LoanTransaction::getTransactionDate)
.max(LocalDate::compareTo)
.orElse(null);
}

private long getDaysInArrears(LocalDate comparisonDate) {
LocalDate lastTransactionDate = getLastTransactionDate();
if (lastTransactionDate == null) {
return 0;
}

return ChronoUnit.DAYS.between(lastTransactionDate, comparisonDate);
}

private static String generateAccountNumber(Client client, LoanProduct loanProduct) {
String clientCode = client.getAccountNumber().substring(0, 4);
String productCode = loanProduct.getShortName();
String sequence = String.format("%04d", ThreadLocalRandom.current().nextInt(1000));
return clientCode + productCode + sequence;
}

// Getters and Setters
public Long getId() { return id; }
public String getAccountNumber() { return accountNumber; }
public Client getClient() { return client; }
public LoanProduct getLoanProduct() { return loanProduct; }
public BigDecimal getPrincipal() { return principal; }
public BigDecimal getPrincipalOutstanding() { return principalOutstanding; }
public LoanStatus getStatus() { return status; }
public LocalDate getSubmittedOnDate() { return submittedOnDate; }
public LocalDate getApprovedOnDate() { return approvedOnDate; }
public LocalDate getActualDisbursementDate() { return actualDisbursementDate; }
public List<LoanTransaction> getTransactions() { return transactions; }
public List<LoanRepaymentSchedule> getRepaymentSchedule() { return repaymentSchedule; }
}

Command Pattern Implementation

Command Handler Architecture:

@Component
public class LoanCommandHandler {

private final LoanRepository loanRepository;
private final ClientRepository clientRepository;
private final LoanProductRepository loanProductRepository;
private final LoanEventPublisher loanEventPublisher;

@Autowired
public LoanCommandHandler(LoanRepository loanRepository,
ClientRepository clientRepository,
LoanProductRepository loanProductRepository,
LoanEventPublisher loanEventPublisher) {
this.loanRepository = loanRepository;
this.clientRepository = clientRepository;
this.loanProductRepository = loanProductRepository;
this.loanEventPublisher = loanEventPublisher;
}

@Transactional
public CommandProcessingResult processCommand(CreateLoanApplicationCommand command) {
try {
// Load entities
Client client = this.clientRepository.findById(command.getClientId())
.orElseThrow(() -> new ClientNotFoundException(command.getClientId()));

LoanProduct loanProduct = this.loanProductRepository.findById(command.getProductId())
.orElseThrow(() -> new LoanProductNotFoundException(command.getProductId()));

// Validate business rules
validateCreateLoanCommand(command, client, loanProduct);

// Create loan
Loan loan = Loan.createFromCommand(command, client, loanProduct);

// Calculate repayment schedule if needed
if (command.isCreateSchedule()) {
List<LoanSchedule> schedule = calculateRepaymentSchedule(loan);
loan.updateRepaymentSchedule(schedule);
}

// Save loan
loan = this.loanRepository.save(loan);

// Publish events
this.loanEventPublisher.publishLoanCreatedEvent(loan);

// Audit
auditLoanCreation(command, loan);

return CommandProcessingResult.of(command.getClientId(), loan.getId());

} catch (DataIntegrityViolationException e) {
return CommandProcessingResult.of(command.getClientId(),
CommandProcessingResult.RESOURCE_ID_TYPES_INVALID);
}
}

@Transactional
public CommandProcessingResult processCommand(ApproveLoanCommand command) {
Loan loan = this.loanRepository.findById(command.getLoanId())
.orElseThrow(() -> new LoanNotFoundException(command.getLoanId()));

validateApproveLoanCommand(command, loan);

loan.approve(command.getApprovedBy(), command.getApprovedDate(),
command.getApprovedPrincipal());

loan = this.loanRepository.save(loan);

this.loanEventPublisher.publishLoanApprovedEvent(loan);

return CommandProcessingResult.of(command.getLoanId(), loan);
}

private void validateCreateLoanCommand(CreateLoanApplicationCommand command,
Client client, LoanProduct loanProduct) {
if (client.isDeleted()) {
throw new ClientDeletedException(command.getClientId());
}

if (loanProduct.isDeleted()) {
throw new LoanProductDeletedException(command.getProductId());
}

if (!loanProduct.hasLoanType(command.getLoanType())) {
throw new LoanProductNotFoundException(command.getProductId());
}

if (command.getPrincipal().compareTo(loanProduct.getMinPrincipal()) < 0) {
throw new LoanAmountLessThanMinAllowedException(
command.getPrincipal(), loanProduct.getMinPrincipal());
}

if (command.getPrincipal().compareTo(loanProduct.getMaxPrincipal()) > 0) {
throw new LoanAmountGreaterThanMaxAllowedException(
command.getPrincipal(), loanProduct.getMaxPrincipal());
}

// Additional validations...
}

private void validateApproveLoanCommand(ApproveLoanCommand command, Loan loan) {
if (!loan.getStatus().isSubmittedAndPendingApproval()) {
throw new LoanNotSubmittedException(loan.getId());
}

if (command.getApprovedPrincipal().compareTo(loan.getPrincipal()) != 0) {
throw new LoanApprovalPrincipalMismatchException(
loan.getPrincipal(), command.getApprovedPrincipal());
}
}
}

Event-Driven Architecture

Event Publisher Implementation:

@Component
public class LoanEventPublisher {

private final ApplicationEventPublisher applicationEventPublisher;
private final ExternalEventService externalEventService;

@Autowired
public LoanEventPublisher(ApplicationEventPublisher applicationEventPublisher,
ExternalEventService externalEventService) {
this.applicationEventPublisher = applicationEventPublisher;
this.externalEventService = externalEventService;
}

public void publishLoanCreatedEvent(Loan loan) {
LoanCreatedEvent event = new LoanCreatedEvent(loan);

// Internal event for same JVM
this.applicationEventPublisher.publishEvent(event);

// External event for other systems
this.externalEventService.publishEvent(convertToExternalEvent(loan, event));
}

public void publishLoanApprovedEvent(Loan loan) {
LoanApprovedEvent event = new LoanApprovedEvent(loan);

this.applicationEventPublisher.publishEvent(event);
this.externalEventService.publishEvent(convertToExternalEvent(loan, event));
}

public void publishLoanDisbursedEvent(Loan loan, MonetaryAmount disbursementAmount) {
LoanDisbursedEvent event = new LoanDisbursedEvent(loan, disbursementAmount);

this.applicationEventPublisher.publishEvent(event);
this.externalEventService.publishEvent(convertToExternalEvent(loan, event));
}

public void publishLoanPaymentAppliedEvent(Loan loan, LoanTransaction paymentTransaction) {
LoanPaymentAppliedEvent event = new LoanPaymentAppliedEvent(loan, paymentTransaction);

this.applicationEventPublisher.publishEvent(event);
this.externalEventService.publishEvent(convertToExternalEvent(loan, event));
}

private ExternalEventPayload convertToExternalEvent(Loan loan, LoanEvent event) {
return ExternalEventPayload.builder()
.eventType(event.getEventType())
.entityType("LOAN")
.entityId(loan.getId())
.tenantId(getCurrentTenantId())
.data(buildLoanEventData(loan, event))
.timestamp(Instant.now())
.correlationId(generateCorrelationId())
.build();
}

private Map<String, Object> buildLoanEventData(Loan loan, LoanEvent event) {
Map<String, Object> data = new HashMap<>();
data.put("loanId", loan.getId());
data.put("accountNumber", loan.getAccountNumber());
data.put("clientId", loan.getClient().getId());
data.put("clientName", loan.getClient().getDisplayName());
data.put("principal", loan.getPrincipal());
data.put("outstandingPrincipal", loan.getPrincipalOutstanding());
data.put("status", loan.getStatus().name());
data.put("productId", loan.getLoanProduct().getId());
data.put("productName", loan.getLoanProduct().getName());

if (event instanceof LoanDisbursedEvent) {
LoanDisbursedEvent disbursedEvent = (LoanDisbursedEvent) event;
data.put("disbursementAmount", disbursedEvent.getDisbursementAmount());
data.put("disbursementDate", disbursedEvent.getDisbursementDate());
}

if (event instanceof LoanPaymentAppliedEvent) {
LoanPaymentAppliedEvent paymentEvent = (LoanPaymentAppliedEvent) event;
data.put("paymentAmount", paymentEvent.getPaymentTransaction().getAmount());
data.put("paymentDate", paymentEvent.getPaymentTransaction().getTransactionDate());
data.put("paymentType", paymentEvent.getPaymentTransaction().getPaymentType());
}

return data;
}
}

// Event Classes
public abstract class LoanEvent extends ApplicationEvent {
private final String eventType;

public LoanEvent(Object source, String eventType) {
super(source);
this.eventType = eventType;
}

public String getEventType() {
return eventType;
}
}

public class LoanCreatedEvent extends LoanEvent {
private final Loan loan;

public LoanCreatedEvent(Loan loan) {
super(loan, "LOAN_CREATED");
this.loan = loan;
}

public Loan getLoan() {
return loan;
}
}

Configuration Management

Application Configuration:

@Configuration
@EnableJpaRepositories(basePackages = "org.apache.fineract.**.repository")
@EntityScan(basePackages = "org.apache.fineract.**.domain")
@SpringBootApplication
public class FineractProviderApplication {

public static void main(String[] args) {
SpringApplication.run(FineractProviderApplication.class, args);
}

@Bean
@Primary
public DataSource dataSource(MultiTenantConnectionProvider multiTenantConnectionProvider,
AbstractDataSourceFactoryBean dataSourceFactoryBean) {

HikariDataSource dataSource = (HikariDataSource) dataSourceFactoryBean.getObject();

// Configuration for multi-tenant
dataSource.setConnectionInitSql(Collections.singletonList(
"SET SCHEMA " + multiTenantConnectionProvider.getCurrentTenantSchema()));

return dataSource;
}

@Bean
public JpaVendorAdapter jpaVendorAdapter() {
HibernateJpaVendorAdapter adapter = new HibernateJpaVendorAdapter();
adapter.setShowSql(false);
adapter.setGenerateDdl(false);
adapter.setDatabasePlatform("org.hibernate.dialect.MySQLDialect");
return adapter;
}

@Bean
@ConfigurationProperties("fineract.jpa")
public LocalContainerEntityManagerFactoryBean entityManagerFactory() {
LocalContainerEntityManagerFactoryBean em = new LocalContainerEntityManagerFactoryBean();
em.setDataSource(dataSource());
em.setPackagesToScan("org.apache.fineract.**.domain");
em.setJpaVendorAdapter(jpaVendorAdapter());
em.setJpaProperties(jpaProperties());
return em;
}

@Bean
@ConfigurationProperties("fineract.jpa")
public Properties jpaProperties() {
Properties properties = new Properties();
properties.setProperty("hibernate.hbm2ddl.auto", "none");
properties.setProperty("hibernate.show_sql", "false");
properties.setProperty("hibernate.format_sql", "false");
properties.setProperty("hibernate.dialect", "org.hibernate.dialect.MySQLDialect");
properties.setProperty("hibernate.jdbc.batch_size", "25");
properties.setProperty("hibernate.order_inserts", "true");
properties.setProperty("hibernate.order_updates", "true");
return properties;
}
}

Security Implementation

Security Configuration:

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig {

private final UserDetailsServiceImpl userDetailsService;
private final PasswordEncoder passwordEncoder;

@Autowired
public SecurityConfig(UserDetailsServiceImpl userDetailsService,
PasswordEncoder passwordEncoder) {
this.userDetailsService = userDetailsService;
this.passwordEncoder = passwordEncoder;
}

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(authz -> authz
// Public endpoints
.requestMatchers("/api/v1/authentication").permitAll()
.requestMatchers("/api/v1/self/**").hasAuthority("READ_SELF_SERVICE_DATA")
.requestMatchers(HttpMethod.POST, "/api/v1/loans").hasAuthority("CREATE_LOAN")
.requestMatchers(HttpMethod.PUT, "/api/v1/loans/**").hasAuthority("UPDATE_LOAN")
.requestMatchers(HttpMethod.POST, "/api/v1/loans/**/commands/**")
.hasAuthority("UPDATE_LOAN")
.requestMatchers("/api/v1/**").hasAuthority("READ_WRITE_FINERACT_API")
.anyRequest().authenticated())
.httpBasic(basic -> basic.realmName("Fineract API"))
.exceptionHandling(exceptions -> exceptions
.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))
.accessDeniedHandler(new HttpStatusEntryPoint(HttpStatus.FORBIDDEN)));

return http.build();
}

@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12);
}

@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration authenticationConfiguration) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}

@Bean
public MethodSecurityExpressionHandler methodSecurityExpressionHandler() {
DefaultMethodSecurityExpressionHandler handler = new DefaultMethodSecurityExpressionHandler();
handler.setPermissionEvaluator(new FineractPermissionEvaluator());
return handler;
}
}

Error Handling

Global Exception Handler:

@ControllerAdvice
public class GlobalExceptionHandler {

private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);

@ExceptionHandler(LoanNotFoundException.class)
public ResponseEntity<ErrorResponse> handleLoanNotFound(LoanNotFoundException e) {
logger.warn("Loan not found: {}", e.getLoanId(), e);

ErrorResponse error = ErrorResponse.builder()
.errorCode("LOAN_NOT_FOUND")
.errorMessage(e.getMessage())
.timestamp(LocalDateTime.now())
.build();

return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
}

@ExceptionHandler(ClientNotFoundException.class)
public ResponseEntity<ErrorResponse> handleClientNotFound(ClientNotFoundException e) {
logger.warn("Client not found: {}", e.getClientId(), e);

ErrorResponse error = ErrorResponse.builder()
.errorCode("CLIENT_NOT_FOUND")
.errorMessage(e.getMessage())
.timestamp(LocalDateTime.now())
.build();

return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
}

@ExceptionHandler(ValidationException.class)
public ResponseEntity<ErrorResponse> handleValidationException(ValidationException e) {
logger.warn("Validation error: {}", e.getMessage(), e);

ErrorResponse error = ErrorResponse.builder()
.errorCode("VALIDATION_ERROR")
.errorMessage(e.getMessage())
.timestamp(LocalDateTime.now())
.addValidationErrors(e.getValidationErrors())
.build();

return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error);
}

@ExceptionHandler(DataIntegrityViolationException.class)
public ResponseEntity<ErrorResponse> handleDataIntegrityViolation(DataIntegrityViolationException e) {
logger.error("Data integrity violation", e);

ErrorResponse error = ErrorResponse.builder()
.errorCode("DATA_INTEGRITY_VIOLATION")
.errorMessage("Data integrity constraint violation")
.timestamp(LocalDateTime.now())
.build();

return ResponseEntity.status(HttpStatus.CONFLICT).body(error);
}

@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGenericException(Exception e) {
logger.error("Unexpected error occurred", e);

ErrorResponse error = ErrorResponse.builder()
.errorCode("INTERNAL_SERVER_ERROR")
.errorMessage("An unexpected error occurred")
.timestamp(LocalDateTime.now())
.build();

return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
}
}

Kesimpulan

Backend Apache Fineract mengimplementasikan arsitektur yang solid dengan:

Kelebihan Utama:

  1. Modular Architecture: Clear separation of concerns melalui modular design
  2. Command Pattern: Consistent handling dari business operations
  3. Event-Driven: Loose coupling melalui event publishing
  4. Multi-Tenant: Efficient tenant isolation dan management
  5. Security: Comprehensive security implementation
  6. Scalability: Stateless design untuk horizontal scaling

Best Practices yang Diterapkan:

  1. Layered Architecture: Clear separation antara controller, service, repository layers
  2. Repository Pattern: Centralized data access dengan custom queries
  3. Transaction Management: Proper transaction handling dengan rollback capabilities
  4. Error Handling: Comprehensive exception handling dan logging
  5. Performance: Optimized database queries dan caching
  6. Testing: Comprehensive unit dan integration testing

Pattern yang Digunakan:

  1. Command Pattern: Untuk business operation handling
  2. Repository Pattern: Untuk data access abstraction
  3. Strategy Pattern: Untuk different interest calculation methods
  4. Observer Pattern: Untuk event handling
  5. Factory Pattern: Untuk entity creation
  6. Template Method Pattern: Untuk batch processing

Arsitektur ini memberikan foundation yang robust untuk enterprise-grade financial services dengan maintainability, scalability, dan reliability yang tinggi.


Dokumentasi ini menjelaskan arsitektur backend secara detail. Implementasi spesifik dapat bervariasi berdasarkan versi dan konfigurasi deployment.