Technical Requirements and Implementation Plan

Technical Requirements and Implementation Plan

Domain-Driven Design & Event-Based Architecture plan.

Executive Summary & Architecture Topology

The VSLA (Self-Funded Group) module is implemented as a standalone Spring Boot application (vsla-service) that sits alongside a standard Apache Fineract deployment. Fineract Core is treated as an external financial ledger and account engine; it is not modified and is consumed exclusively through the official Apache Fineract Java REST API Client (fineract-client).

Topology

image-20260526-175000.png

 

image-20260526-174944.png

 

Key Principles

  • VSLA Service is the System of Record for all VSLA-specific domain logic: group parameters, meeting schedules, attendance, internal lending lifecycle, voting, share-out calculations, earnings allocation, and group-level accounting.

  • Fineract Core is the System of Record for: client registry, savings account balances, external loans (group ↔ bank/MFI), and institution-level GL postings.

  • All cross-system mutations are asynchronous and eventually consistent, using an Outbox + Inbox pattern over the Fineract REST API.

  • No shared database; loose coupling via REST only.

  • Trapdoor setting (vsla.group-type) is a deployment-time configuration in the VSLA service. When active, all group lifecycle and internal lending operations are owned by vsla-service; Fineract remains unaware of group semantics.

Integration Strategy with Fineract Core

Official Fineract Java REST API Client

The VSLA service consumes the fineract-client JAR (autogenerated from Fineract’s OpenAPI spec, Retrofit2/Feign/Spring Web Client/-based).

@Bean

public FineractClient fineractClient(VslaFineractProperties props) {

return FineractClient.builder()

.baseURL(props.getBaseUrl()) // e.g., https://fineract.internal/fineract-provider/api/v1/

.tenant(props.getTenantId())

.basicAuth(props.getServiceUsername(), props.getServicePassword())

.build();

}

Service Account Principle:

A dedicated Fineract user (“vlsa“) is created with minimal permissions:

  • CREATE_CLIENT

  • CREATE_SAVINGSACCOUNT

  • CREATE_LOAN

  • CREATE_JOURNALENTRY

  • READ_REPORT

  • LOAN_TRANSACTIONS

  • SAVINGS_ACCOUNT_TRANSACTIONS

Fineract APIs Consumed

Fineract Module

API Endpoints Used

Purpose in VSLA Context

Fineract Module

API Endpoints Used

Purpose in VSLA Context

Clients

POST /clients, GET /clients/{id}, PUT /clients/{id}

Member onboarding & KYC sync. Each VSLA member is mirrored as a Fineract Client.

Savings Accounts

POST /savingsaccounts, POST /savingsaccounts/{id}?command=approve|activate, POST /savingsaccounts/{id}/transactions?command=deposit|withdrawal, GET /savingsaccounts/{id}

Group pooled-fund accounts, individual member share/savings accounts, and cash-equivalent tracking.

Account Transfers

POST /accounttransfers

Moving money between group savings and member savings (internal loan disbursement / repayment).

Loans

POST /loans, POST /loans/{id}?command=approve|disburse, POST /loans/{id}/transactions?command=repayment, GET /loans/{id}

External funding only: loans taken by the group from a bank/MFI. Internal member loans are NOT represented as Fineract loan accounts.

Accounting

POST /journalentries, GET /glaccounts

Posting group-level summary entries (e.g., earnings allocation) to Fineract GL for institutional consolidation.

Users / Offices

GET /users, GET /offices

Read-only sync for user-group mapping validation and office hierarchy.

Outbox Pattern for Eventually Consistent Integration

Important: Let's see if we can pull it off initially by just using Spring application events. For resilience the outbox pattern is the preferred solution, but instead of a fixed scheduler a CDC solution like Debezium could be used for real-time processing.

Because the VSLA service database and Fineract are not enrolled in a distributed transaction, all outbound REST calls to Fineract are deferred through an Outbox table within the VSLA database. This guarantees at-least-once delivery with idempotency.

@Entity

@Table(name = "outbox_events")

public class OutboxEvent {

@Id private UUID id;

private String aggregateType; // e.g., "GroupCapital"

private String aggregateId; // group UUID

private String eventType; // e.g., "SharesPurchased"

private String destination; // e.g., "fineract.savings.deposit"

@Column(columnDefinition = "jsonb") private String payload;

@Enumerated(STRING) private OutboxStatus status; // PENDING, PROCESSED, FAILED

private int retryCount;

private Instant createdAt;

private String errorMessage;

}

Flow:

  1. Command handler updates VSLA aggregate and appends a domain event (same local TX).

  2. An event listener inserts an OutboxEvent row (same local TX).

  3. A background @Scheduled processor polls PENDING rows, executes the Fineract REST call via fineract-client, and marks PROCESSED or FAILED.

  4. Fineract APIs use externalId (or idempotency keys derived from OutboxEvent.id) to prevent duplicate financial postings on retries.

@Component

@RequiredArgsConstructor

public class FineractOutboxProcessor {

private final OutboxRepository outboxRepo;

private final FineractClient fineract;

private final Map<String, FineractCommandHandler> handlers;

@Scheduled(fixedDelayString = "${vsla.outbox.poll-ms:5000}")

@Transactional

public void processPending() {

List<OutboxEvent> pending = outboxRepo.findTop100ByStatusOrderByCreatedAtAsc(PENDING);

for (OutboxEvent event : pending) {

try {

handlers.get(event.getDestination()).handle(event);

event.markProcessed();

} catch (Exception ex) {

event.fail(ex.getMessage()); // dead-letter after max retries

}

}

}

}

Strategic Design - Bounded Contexts (Inside VSLA Service)

The VSLA service itself is organized as vertical slices (bounded contexts) communicating via domain events.

Bounded Context

Responsibility

Integration with Fineract

Bounded Context

Responsibility

Integration with Fineract

Group Lifecycle

Group creation, cycle dates, meeting schedules, member enrollment, operational parameters (shares, penalties, loan products)

Creates/updates Fineract Client records for members via Outbox.

Capital & Shares

Mandatory share purchases, member savings tracking, corpus computation, share-out estimation

Orchestrates Fineract savings account creation and deposit/withdrawal transactions via Outbox.

Internal Lending

Member loans from pooled funds, eligibility rules, voting approval, repayment scheduling, interest/fine calculation

Does NOT create Fineract Loan accounts. Tracks loans in VSLA DB; uses Fineract accounttransfers or savingsaccounts/transactions to move actual funds.

External Funding

Group borrowing from banks/MFIs, government grants, revolving funds

Creates Fineract Loan accounts (group as borrower) and tracks repayment schedules via Outbox.

Group Accounting

Group-level COA, trial balance, P&L, balance sheet, earnings allocation, bonus deposits, investments

Maintains own GL in VSLA DB. Optionally posts summary journal entries to Fineract Accounting API for institutional consolidation.

Membership & Governance

Attendance, lateness, voting power, grading/loyalty, user-to-group mapping

Read-only sync with Fineract Users API; enforces VSLA-specific authorization rules.

Mobile & Payments

Offline sync, YAPE/PLIN webhooks, mobile dashboards, conflict resolution

Receives webhooks directly; translates to VSLA commands and Outbox entries for Fineract savings deposits.

Context Map

  • Group Lifecycle → upstream context publishing SelfFundedGroupCreated, MemberEnrolled, MeetingHeld.

  • Capital & Shares → downstream consumer of Group Lifecycle; customer-supplier to Fineract Savings API.

  • Internal Lending → downstream consumer of Capital events; uses Fineract Account Transfers API for fund movements.

  • Group Accounting → published-language subscriber to all monetary domain events; produces no Fineract side-effects except optional summary JE postings.

Aggregates, Entities & Value Objects

We don’t have to be too dogmatic about this, but let’s make an effort and see where it takes us. I think this strategy is well suited for maintainable code and could be an org wide template for similar projects.

Aggregates (Transaction Boundaries in VSLA DB)

Aggregate

Root Entity

Invariants

Aggregate

Root Entity

Invariants

SelfFundedGroup

SelfFundedGroup

Cycle dates immutable once active; min/max shares per meeting enforced; member sequence numbers unique.

GroupCapital

GroupCapital

Corpus = Σ member share balances + Σ external borrowings + accumulated earnings – investments – internal loans outstanding.

InternalLoan

InternalLoan

Eligibility rule must pass before application accepted; approval requires quorum votes (≥ N members); interest split (group/cooperative) computed on every repayment.

GroupLedger

GroupLedger

Every monetary domain event produces a group-level GL entry; trial balance must always balance per group.

MemberEnrollment

MemberEnrollment

Attendance streak affects bonus eligibility; voting power = shares held × votesPerShare.

Value Objects

public record ShareSpecification(

Money valueOfOneShare,

int minSharesPerMeeting,

int maxSharesPerMeeting,

int votesPerShare

) {}

public record MeetingSchedule(

MeetingPattern pattern,

Set<LocalDate> scheduledDates,

LocalTime meetingTime

) {}

public record LoanEligibilityRule(

String ruleExpression,

int minMonthsInGroup

) {}

public record EarningsAllocationRatio(

BigDecimal groupRetentionPercent,

BigDecimal cooperativePercent

) {}

public record FineractAccountMapping(

String fineractSavingsAccountId, // external ID in Fineract

String fineractClientId // external member ID in Fineract

) {}

Domain Events (Internal to VSLA Service)

These events drive CQRS read models and Outbox integration within the VSLA service.

Flat structures preferred, we should avoid nested objects for events, requests and responses!

public sealed interface DomainEvent {

GroupId groupId();

LocalDateTime occurredOn();

}

public record SelfFundedGroupCreated(

GroupId groupId, OfficeId officeId, CycleDates cycleDates, ShareSpecification shareSpec

) implements DomainEvent {}

public record MemberEnrolled(

GroupId groupId, MemberId memberId, int sequenceNumber, LocalDate joinDate

) implements DomainEvent {}

public record SharesPurchased(

GroupId groupId, MemberId memberId, int quantity, Money totalAmount, MeetingId meetingId

) implements DomainEvent {}

public record MeetingHeld(

GroupId groupId, MeetingId meetingId, LocalDate meetingDate, Set<MemberId> attendees

) implements DomainEvent {}

public record PenaltyApplied(

GroupId groupId, MemberId memberId, PenaltyType type, Money amount, MeetingId meetingId

) implements DomainEvent {}

public record InternalLoanApplied(

GroupId groupId, MemberId memberId, LoanId loanId, Money principal, InterestRate rate

) implements DomainEvent {}

public record InternalLoanApproved(

GroupId groupId, LoanId loanId, List<MemberVote> votes

) implements DomainEvent {}

public record InternalLoanRepaid(

GroupId groupId, LoanId loanId, Money principalPortion, Money interestPortion,

Money groupRetainedEarnings, Money cooperativeEarnings

) implements DomainEvent {}

public record ExternalFundingReceived(

GroupId groupId, FundingSourceId sourceId, Money amount, InterestRate borrowingRate

) implements DomainEvent {}

public record EarningsAllocated(

GroupId groupId, Money totalEarnings, Map<MemberId, Money> memberAllocations,

SavingsAccountId targetSavingsProduct

) implements DomainEvent {}

public record ShareOutComputed(

GroupId groupId, CycleId cycleId, Map<MemberId, Money> estimatedPayout

) implements DomainEvent {}

public record MobilePaymentReceived(

GroupId groupId, PaymentGateway gateway, String transactionReference, Money amount

) implements DomainEvent {}

public record FineractSyncRequired(

GroupId groupId, String destination, String fineractPayload, UUID outboxEventId

) implements DomainEvent {}

New Command Processing (Inside VSLA Service)

Re-use fineract-command library and maybe extend it to support “queries” (aka the “Q” in CQRS).

The VSLA service implements its own type-safe command dispatcher, inspired by Fineract's new command processing direction but fully independent. Commands are immutable records; handlers are pure application services that load aggregates, invoke domain behavior, persist, and publish events.

We could try to drop the service concept altogether and use as single concern pure business logic services.

Command DTOs

Naming patterns:

  • [Domain Aggregate] + [Action] + Request

  • [Domain Aggregate] + [Action] + Response

  • [Domain Aggregate] + [Action] + Command

  • [Domain Aggregate] + [Action] + CommandHandler

Example: SelfFundedGroupCreateRequest

See also: https://github.com/apache/fineract/blob/develop/fineract-doc/src/docs/en/chapters/command/refactoring.adoc

@Builder

@Data

@NoArgsConstructor

@AllArgsConstructor

@FieldNameConstants

public class SelfFundedGroupCreateRequest implements Serializable {

@Serial

private static final long serialVersionUID = 1L;

@NotBlank

private String groupName;

@NotNull

private OfficeId officeId;

@NotNull

private @Valid MeetingSchedule meetingSchedule;

@NotNull

private @Valid ShareSpecification shareSpecification;

@NotNull

private LocalDate cycleStartDate;

@NotNull

private LocalDate cycleEndDate;

@NotNull

private UserId createdBy;

}

@Builder

@Data

@NoArgsConstructor

@AllArgsConstructor

@FieldNameConstants

public class MeetingAttendanceRecordRequest implements Serializable {

@Serial

private static final long serialVersionUID = 1L;

@NotNull

private GroupId groupId;

@NotNull

private MeetingId meetingId;

@NotEmpty

private Set<MemberAttendance> attendances;

@NotNull

private LocalDate meetingDate;

}

@Builder

@Data

@NoArgsConstructor

@AllArgsConstructor

@FieldNameConstants

public class SharesPurchaseRequest implements Serializable {

@Serial

private static final long serialVersionUID = 1L;

@NotNull

private GroupId groupId;

@NotNull

private MemberId memberId;

@Min(1)

private int numberOfShares;

@NotNull

private Money amount;

@NotNull

private PaymentDetail paymentDetail;

@NotNull

private MeetingId meetingId;

}

@Builder

@Data

@NoArgsConstructor

@AllArgsConstructor

@FieldNameConstants

public class InternalLoanApplyRequest implements Serializable {

@Serial

private static final long serialVersionUID = 1L;

@NotNull

private GroupId groupId;

@NotNull

private MemberId memberId;

@NotNull

private @Valid LoanEligibilityRule eligibilityRule;

@NotNull

private Money principal;

@NotNull

private InterestRate proposedInterestRate;

@Min(1)

private int numberOfInstallments;

}

@Builder

@Data

@NoArgsConstructor

@AllArgsConstructor

@FieldNameConstants

public class InternalLoanApproveRequest implements Serializable {

@Serial

private static final long serialVersionUID = 1L;

@NotNull

private GroupId groupId;

@NotNull

private LoanId loanId;

@NotEmpty

private List<MemberVote> votes;

}

@Builder

@Data

@NoArgsConstructor

@AllArgsConstructor

@FieldNameConstants

public class ExternalFundingReceiveRequest implements Serializable {

@Serial

private static final long serialVersionUID = 1L;

@NotNull

private GroupId groupId;

@NotNull

private FundingSourceId sourceId;

@NotNull

private Money amount;

@NotNull

private InterestRate borrowingRate;

@NotNull

private LocalDate disbursementDate;

}

@Builder

@Data

@NoArgsConstructor

@AllArgsConstructor

@FieldNameConstants

public class MobilePaymentReceiveRequest implements Serializable {

@Serial

private static final long serialVersionUID = 1L;

@NotNull GroupId groupId;

@NotNull PaymentGateway gateway;

@NotBlank String externalTransactionId;

@NotNull Money amount;

@NotNull Instant receivedAt;

}

Command Dispatcher & Handlers

public interface CommandHandler<REQ, RES> {

RES handle(REQ command);

}

public interface CommandDispatcher {

<RES> RES dispatch(REQ command);

}

More details on new command processing:

Example Handler

@Service

@RequiredArgsConstructor

public class SharesServiceImpl implements SharesService {

private final SelfFundedGroupRepository groupRepo;

private final GroupCapitalRepository capitalRepo;

// ...

@Override

public SharesPurchaseResponse purchase(SharesPurchaseRequest request) {

var group = groupRepo.findById(command.groupId())

.orElseThrow(() -> new GroupNotFoundException(command.groupId()));

var capital = capitalRepo.findByGroupId(command.groupId());

// domain behavior

group.validateMeetingIsOpen(command.meetingId());

capital.creditShares(command.memberId(), command.numberOfShares(), command.amount());

// persist

groupRepo.save(group);

capitalRepo.save(capital);

return SharesPurchaseResponse.builder().groupId(command.groupId().value()).build();

}

// ...

}