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
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 byvsla-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 |
|---|---|---|
Clients |
| Member onboarding & KYC sync. Each VSLA member is mirrored as a Fineract Client. |
Savings Accounts |
| Group pooled-fund accounts, individual member share/savings accounts, and cash-equivalent tracking. |
Account Transfers |
| Moving money between group savings and member savings (internal loan disbursement / repayment). |
Loans |
| External funding only: loans taken by the group from a bank/MFI. Internal member loans are NOT represented as Fineract loan accounts. |
Accounting |
| Posting group-level summary entries (e.g., earnings allocation) to Fineract GL for institutional consolidation. |
Users / 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:
Command handler updates VSLA aggregate and appends a domain event (same local TX).
An event listener inserts an
OutboxEventrow (same local TX).A background
@Scheduledprocessor pollsPENDINGrows, executes the Fineract REST call viafineract-client, and marksPROCESSEDorFAILED.Fineract APIs use
externalId(or idempotency keys derived fromOutboxEvent.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 |
|---|---|---|
Group Lifecycle | Group creation, cycle dates, meeting schedules, member enrollment, operational parameters (shares, penalties, loan products) | Creates/updates Fineract |
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 |
External Funding | Group borrowing from banks/MFIs, government grants, revolving funds | Creates Fineract |
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 |
|---|---|---|
|
| Cycle dates immutable once active; min/max shares per meeting enforced; member sequence numbers unique. |
|
| Corpus = Σ member share balances + Σ external borrowings + accumulated earnings – investments – internal loans outstanding. |
|
| Eligibility rule must pass before application accepted; approval requires quorum votes (≥ N members); interest split (group/cooperative) computed on every repayment. |
|
| Every monetary domain event produces a group-level GL entry; trial balance must always balance per group. |
|
| 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
@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();
}
// ...
}