1. Background
1.1 Business Reorganization
As product requirements evolved, the consumer installment system needed to accommodate new product directions. The existing services, built up over time, could no longer support the revised business model. A fresh architecture was required.
1.2 Addressing Technical Debt
Key problems:
- Module boundaries were unclear; services were split only by product lines, leaving fundamental capabilities duplicated.
- Code lacked clear layering; many methods were long procedural scripts.
- Maintenance costs grew because shared logic was not extracted, causing repetition across modules.
1.3 Developer Productivity
Even with deep familiarity, tracing issues through tangled call chains was time-consuming. A restructured design was needed to improve long-term development speed.
1.4 Inadequate Monitoring
The alerting system lacked sensitivity for core business metrics. Developers needed dashboards that surface anomalies early.
2. Refactoring Goals
- Keep existing business running and iterating normally.
- Improve code structure and extensibility to boost development efficiency.
- Gradually replace legacy services with a new implementation; retire old code safely.
3. Design
3.1 Research on Industry Patterns
Before refactoring, we studied common architectural patterns in consumer finance. The generic layered approach provided good inspiration, though it needed adaptation to our specific product features.
3.2 Rollout Plan
The effort was split into two phases to reduce risk. The first phase extracted core backend modules into new services while preserving legacy edge logic. The second phase integrated those new services with the frontend for the updated product flows.
3.3 Strangler Fig Approach
During the first phase, we used the Strangler Fig pattern: core logic was reimplemented in a new project, leaving peripheral legacy code untouched but isolated. New services gradually took over responsibilities, providing RPC APIs to the old system. After the new system stabilized, the second phase connected the new backend to the updated frontend, bringing the V2 system live.
3.4 Horizontal Decomposition (Domains)
We partitioned the system into three domains:
- Aggregate Business: user-facing flows that orchestrate credit steps for different buy‑now‑pay‑later products, exposing unified APIs to the cashier and front end.
- Foundational Services: data from user credit profiles and partner‑supplied information, forming a reusable data layer for all installment services.
- Third‑party Integration: adapter layer that implements partner‑specific protocols behind a standardized internal API.
3.5 Vertical Decomposition (Modules)
Based on the lifecycle of a purchase — apply for credit, receive a limit, place an order, generate repayment plan, bind card, and repay — we extracted common building blocks: pre‑credit evaluation, loan utilization, billling, and repayment. These shared financial capabilities were stripped out of the product‑specific aggregate layer and maintained independently. The product‑specific logic remained in its own cohesive modules, no longer intertwined with reusable components.
The design followed single responsibility and dependency inversion principles: we reshaped package boundaries, kept dependencies flowing inward, and reduced coupling through clear interface contracts.
3.6 Code Design
To replace the old monolithic strategy pattern, we introduced a double‑layer combination of Template Method, Strategy, and Factory patterns. This separates the reusable business flow from the partner‑specific API details.
Layer one is the foundational service strategy, defining standard operations such as credit inquiry, loan disbursement, and post‑loan status checks. Layer two is the partner integration layer, which adapts each external partner’s specific API to the internal standard.
The following examples illustrate the structure for the credit inquiry module.
// 1. Core service interface for underwriting
public interface IUnderwritingService {
String getPartnerId();
String getProductLabel();
UnderwritingResult performCheck(String traceId, Long userId);
}
// 2. Abstract template with standard flow
public abstract class AbstractUnderwritingService implements IUnderwritingService {
protected abstract IExternalGateway getGateway();
@Override
public UnderwritingResult performCheck(String traceId, Long userId) {
UnderwritingRequest request = UnderwritingRequest.builder()
.userId(userId)
.build();
ExternalServiceResponse<UnderwritingResponse> rawResp =
getGateway().callCreditCheck(traceId, request);
String status = MappingUtils.mapApprovalStatus(rawResp.getPayload());
return UnderwritingResult.builder()
.approvalStatus(status)
.build();
}
}
// 3. Standard API contract for external partners
public interface IExternalGateway {
String getGatewayId();
ExternalServiceResponse<UnderwritingResponse> callCreditCheck(
String traceId, UnderwritingRequest request);
}
// 4. Abstract base for partner gateways with common plumbing
public abstract class AbstractExternalGateway implements IExternalGateway {
protected abstract PartnerConfig getConfig();
@Override
public ExternalServiceResponse<UnderwritingResponse> callCreditCheck(
String traceId, UnderwritingRequest request) {
// common encryption, signature, HTTP call
return executeJsonCall(
traceId,
getConfig().getCreditCheckUrl(),
request,
UnderwritingResponse.class);
}
}
// 5. Partner-specific adapter (for example, ABC Bank)
public interface IAbcGateway extends IExternalGateway {
// extends/overrides only if different
}
public abstract class AbstractAbcGateway extends AbstractExternalGateway
implements IAbcGateway {
@Override
public ExternalServiceResponse<UnderwritingResponse> callCreditCheck(
String traceId, UnderwritingRequest request) {
// possibly different encryption or header logic
return executeCustomCall(traceId, getConfig().getCreditCheckUrl(),
request, UnderwritingResponse.class);
}
}
// 6. Concrete implementations
@Service
public class ZzAbcGateway extends AbstractAbcGateway {
@Override
public String getGatewayId() {
return PartnerId.ABC_ZZ.getValue();
}
}
@Service
public class ZzAbcUnderwriting extends AbstractUnderwritingService {
private final ZzAbcGateway gateway;
public ZzAbcUnderwriting(ZzAbcGateway gateway) {
this.gateway = gateway;
}
@Override
protected IExternalGateway getGateway() {
return gateway;
}
@Override
public String getPartnerId() {
return PartnerId.ABC_ZZ.getValue();
}
@Override
public String getProductLabel() {
return ProductLabel.ZZ.getValue();
}
}
This double-layer pattern allows new partners to be added by implementing only IExternalGateway and an optional adapter, while the business flow in AbstractUnderwritingService stays unchanged. The same pattern is applied to loan disbursement, billing, and post‑loan modules.
4. Rollout Strategy
Since the new system used a redesigned database schema, we set up one‑way data synchronization from the old tables to the new ones. The new services were gradually enabled behind feature flags. If a rollback was necessary during the canary phase, we would revert the data sync direction and restore old data, always prioritizing stability.
The two-phase iterative approach let us validate the new backend thoroughly before exposing it to end users.
5. Monitoring
Refactoring without monitoring is risky. We inteegrated the company’s alerting framework with Prometheus‑based metrics and built operational dashboards for key indicators — approval rates, timeouts, and repayment latencies. Consistent structured logging was enforced across all modules, providing a fast path to diagnose issues.