Skip to content

5.12 Development conventions

This page is the first-day reference for an engineer joining the platform team. It covers the repo layout, naming, conventions, testing, and contribution flow. The goal: a new engineer can be productive on a small change within a week, without reverse-engineering norms from existing code.

A single mono-repo for the modular monolith plus shared infrastructure code.

b2b-lending-platform/
├── pom.xml # Maven root (or build.gradle for Gradle)
├── core-platform/ # The modular monolith
│ ├── pom.xml
│ ├── src/
│ │ ├── main/
│ │ │ ├── java/in/yourorg/platform/
│ │ │ │ ├── PlatformApplication.java # @SpringBootApplication
│ │ │ │ ├── config/
│ │ │ │ │ ├── SecurityConfig.java
│ │ │ │ │ ├── DataSourceConfig.java
│ │ │ │ │ ├── WorkflowConfig.java
│ │ │ │ │ └── ObservabilityConfig.java
│ │ │ │ ├── shared/
│ │ │ │ │ ├── audit/
│ │ │ │ │ ├── consent/
│ │ │ │ │ ├── pii/
│ │ │ │ │ ├── tracing/
│ │ │ │ │ └── exception/
│ │ │ │ └── modules/
│ │ │ │ ├── acquisition/
│ │ │ │ │ ├── api/ # REST controllers
│ │ │ │ │ ├── application/ # use-cases / services
│ │ │ │ │ ├── domain/ # entities, value objects
│ │ │ │ │ ├── infrastructure/ # repos, external adapters
│ │ │ │ │ └── AcquisitionModule.java # @Configuration entry
│ │ │ │ ├── application/
│ │ │ │ ├── kyc/
│ │ │ │ ├── ingestion/
│ │ │ │ ├── decisioning/
│ │ │ │ ├── manualreview/
│ │ │ │ ├── colending/
│ │ │ │ ├── documentation/
│ │ │ │ ├── disbursement/
│ │ │ │ ├── lms/
│ │ │ │ ├── collections/
│ │ │ │ ├── monitoring/
│ │ │ │ ├── accounting/
│ │ │ │ ├── reporting/
│ │ │ │ ├── admin/
│ │ │ │ └── notification/
│ │ │ └── resources/
│ │ │ ├── application.yml
│ │ │ ├── application-prod.yml
│ │ │ ├── db/migration/ # Flyway migrations
│ │ │ └── openapi.yaml # generated
│ │ └── test/
│ │ ├── java/... # mirror of main
│ │ └── resources/
│ │ ├── application-test.yml
│ │ └── fixtures/
├── integrations/ # Vendor adapter implementations
│ ├── bureau-cibil/
│ ├── bureau-experian/
│ ├── kyc-karza/
│ ├── kyc-idfy/
│ ├── aa-setu/
│ ├── bsa-perfios/
│ ├── gst-cygnet/
│ ├── esign-leegality/
│ ├── esign-digio/
│ ├── nach-digio/
│ ├── payment-razorpay/
│ ├── payout-razorpayx/
│ ├── sms-gupshup/
│ ├── whatsapp-gupshup/
│ ├── ivr-exotel/
│ └── partner-bank-{name}/
├── async-workers/ # Separate deployment for async/scheduled
│ └── pom.xml
├── partner-gateway/ # Separate deployment for partner-facing
│ └── pom.xml
├── frontend/
│ ├── borrower-portal/ # React + Vite + TS
│ ├── partner-portal/
│ ├── admin-console/
│ └── field-agent-app/ # React Native
├── infrastructure/
│ ├── terraform/ # IaC
│ ├── helm/ # Kubernetes manifests
│ └── ci/ # CI/CD workflows
├── docs/ # This site
├── scripts/ # Dev utilities
├── .github/workflows/
└── README.md

Each module has its own package; inter-module calls must go through public APIs, not direct repository / entity access.

// Allowed
@Service
public class DisbursementService {
private final SanctionService sanctionService; // public API
private final MandateService mandateService; // public API
// ...
public DisbursementInstruction createInstruction(Long sanctionId, BigDecimal amount) {
Sanction sanction = sanctionService.getById(sanctionId); // OK
// ...
}
}
// NOT allowed
@Service
public class DisbursementService {
private final SanctionRepository sanctionRepo; // direct DB access to another module — BANNED
}

Enforce via ArchUnit tests in CI:

@AnalyzeClasses(packages = "in.yourorg.platform")
public class ModuleBoundaryTest {
@ArchTest
public static final ArchRule modules_should_only_depend_via_api =
classes()
.that().resideInAPackage("..modules.disbursement..")
.should().onlyDependOnClassesThat(
resideInAnyPackage(
"..modules.disbursement..", // own module
"..modules.lms.api..", // public API of LMS
"..modules.sanction.api..",
"..shared..",
"java..", "org.springframework..", "..."
)
);
}
ElementConventionExample
Java packageslowercase, dot-separatedin.yourorg.platform.modules.disbursement
Java classesPascalCaseDisbursementService, LoanAccount
Java methodscamelCasecreateInstruction, markAsDisbursed
ConstantsUPPER_SNAKEMAX_RETRY_ATTEMPTS
Database tables / columnssnake_caseloan_account, current_outstanding
REST endpointskebab-case in path; resource plural/v1/loan-accounts/{id}
JSON keyssnake_case (matching DB)loan_account_id, current_outstanding
Event topicsdot-separated, lowercaseloan.activated, disbursement.executed
Workflow IDsUUID + descriptive prefixsanc-{uuid}, disb-{uuid}
application.yml
spring:
application:
name: core-platform
datasource:
url: jdbc:postgresql://localhost:5432/platform
username: ${DB_USER}
password: ${DB_PASSWORD}
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
jpa:
hibernate:
ddl-auto: validate # Never auto in any environment
open-in-view: false # Avoid Lazy-load issues
flyway:
enabled: true
locations: classpath:db/migration
jackson:
property-naming-strategy: SNAKE_CASE
default-property-inclusion: non_null
management:
endpoints:
web:
exposure:
include: health,info,prometheus
endpoint:
health:
probes:
enabled: true
logging:
level:
root: INFO
in.yourorg.platform: DEBUG
pattern:
console: '{"timestamp":"%d{ISO8601}","level":"%level","service":"${spring.application.name}","traceId":"%X{traceId}","logger":"%logger","message":"%msg"}%n'
@Service
@RequiredArgsConstructor
@Slf4j
@Transactional
public class DisbursementService {
private final DisbursementRepository repo;
private final SanctionService sanctionService;
private final MandateService mandateService;
private final PayoutAdapter payoutAdapter;
private final EventPublisher eventPublisher;
private final AuditService auditService;
public DisbursementInstruction createInstruction(CreateDisbursementCommand cmd) {
// 1. Validate
Sanction sanction = sanctionService.getById(cmd.sanctionId());
Validator.validateAmount(cmd.amount(), sanction.getRemainingLimit());
// 2. Build domain object
DisbursementInstruction instruction = DisbursementInstruction.builder()
.sanctionId(cmd.sanctionId())
.amount(cmd.amount())
.toAccountToken(cmd.toAccountToken())
.status(DisbursementStatus.PENDING_APPROVAL)
.build();
// 3. Persist
repo.save(instruction);
// 4. Audit
auditService.record("disbursement.created", instruction);
// 5. Emit event
eventPublisher.publish("disbursement.created", instruction.toEvent());
log.info("Disbursement created: id={} amount={}", instruction.getId(), instruction.getAmount());
return instruction;
}
// ...
}
@RestController
@RequestMapping("/v1/disbursements")
@RequiredArgsConstructor
@Validated
public class DisbursementController {
private final DisbursementService service;
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public DisbursementResponse create(
@Valid @RequestBody CreateDisbursementRequest request,
@RequestHeader("X-Idempotency-Key") String idempotencyKey,
@AuthenticationPrincipal ServicePrincipal principal
) {
var command = new CreateDisbursementCommand(
request.sanctionId(),
request.amount(),
request.toAccountToken(),
idempotencyKey,
principal.userId()
);
var result = service.createInstruction(command);
return DisbursementResponse.from(result);
}
@GetMapping("/{id}")
public DisbursementResponse get(@PathVariable Long id) {
var instruction = service.getById(id);
return DisbursementResponse.from(instruction);
}
}
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ValidationException.class)
public ResponseEntity<ErrorResponse> validation(ValidationException ex) {
return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY)
.body(new ErrorResponse("VALIDATION_ERROR", ex.getMessage(), ex.getDetails()));
}
@ExceptionHandler(EntityNotFoundException.class)
public ResponseEntity<ErrorResponse> notFound(EntityNotFoundException ex) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse("NOT_FOUND", ex.getMessage(), null));
}
@ExceptionHandler(VendorException.class)
public ResponseEntity<ErrorResponse> vendor(VendorException ex) {
return ResponseEntity.status(HttpStatus.BAD_GATEWAY)
.body(new ErrorResponse("VENDOR_ERROR", ex.getMessage(),
Map.of("vendor", ex.getVendor())));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> generic(Exception ex) {
log.error("Unhandled exception", ex);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ErrorResponse("INTERNAL_ERROR", "An unexpected error occurred", null));
}
}
@ExtendWith(MockitoExtension.class)
class DisbursementServiceTest {
@Mock SanctionService sanctionService;
@Mock MandateService mandateService;
@Mock DisbursementRepository repo;
@Mock EventPublisher eventPublisher;
@Mock AuditService auditService;
@InjectMocks DisbursementService service;
@Test
void createInstruction_happyPath() {
// given
var sanction = aSanction().withRemainingLimit("500000").build();
when(sanctionService.getById(1L)).thenReturn(sanction);
var cmd = new CreateDisbursementCommand(1L, new BigDecimal("100000"),
"tok_acc_1", "idem-1", 401L);
// when
var result = service.createInstruction(cmd);
// then
assertThat(result.getStatus()).isEqualTo(DisbursementStatus.PENDING_APPROVAL);
verify(eventPublisher).publish(eq("disbursement.created"), any());
verify(auditService).record(eq("disbursement.created"), any());
}
@Test
void createInstruction_exceedsLimit_throws() {
var sanction = aSanction().withRemainingLimit("50000").build();
when(sanctionService.getById(1L)).thenReturn(sanction);
var cmd = new CreateDisbursementCommand(1L, new BigDecimal("100000"),
"tok_acc_1", "idem-1", 401L);
assertThatThrownBy(() -> service.createInstruction(cmd))
.isInstanceOf(ValidationException.class);
}
}
@SpringBootTest
@Testcontainers
@AutoConfigureMockMvc
class DisbursementIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15")
.withDatabaseName("test")
.withUsername("test")
.withPassword("test");
@DynamicPropertySource
static void postgresProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
}
@Autowired MockMvc mvc;
@Test
void createDisbursement_persistsCorrectly() throws Exception {
mvc.perform(post("/v1/disbursements")
.header("X-Idempotency-Key", "test-idem-1")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{ "sanction_id": 1, "amount": 100000, "to_account_token": "tok_x" }
"""))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.status").value("PENDING_APPROVAL"));
}
}
public class SanctionTestBuilder {
private Long id = 1L;
private BigDecimal remainingLimit = new BigDecimal("500000");
public SanctionTestBuilder withId(Long id) { this.id = id; return this; }
public SanctionTestBuilder withRemainingLimit(String amount) {
this.remainingLimit = new BigDecimal(amount);
return this;
}
public Sanction build() {
return Sanction.builder().id(id).remainingLimit(remainingLimit).build();
}
public static SanctionTestBuilder aSanction() { return new SanctionTestBuilder(); }
}

Every PR must satisfy:

  • Module boundary: no cross-module direct DB access; only public APIs.
  • Tests added: unit + integration where appropriate; coverage > 70% on changed code.
  • Migrations: if DB schema changed, Flyway migration added; reversible if reasonable.
  • Idempotency: every mutating API has Idempotency-Key handling.
  • Audit: every state-affecting action records an audit event.
  • Events: any state change that other modules care about emits an event.
  • PII: no clear-text PII in new code; tokens used; no PII in logs.
  • Errors: appropriate HTTP status; error code from taxonomy.
  • Observability: log lines with traceId; metrics on critical paths.
  • Documentation: OpenAPI annotations; module README updated if needed.
  • Security: input validation; auth/RBAC check; no SQL injection paths.
  • Performance: no N+1 query; indexes for new query patterns; connection-pool implications.
  • Vendor: any new vendor call goes through the adapter framework with retry / circuit breaker.
  • Backwards compat: no breaking changes to existing APIs without versioning.
  • Linting: SonarQube / Checkstyle clean.
.github/workflows/build.yml
name: Build and Test
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_DB: test
POSTGRES_USER: test
POSTGRES_PASSWORD: test
ports: [5432:5432]
options: --health-cmd pg_isready
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: maven
- name: Compile
run: mvn -B compile
- name: Unit tests
run: mvn -B test
- name: Integration tests
run: mvn -B verify -P integration
- name: ArchUnit module-boundary checks
run: mvn -B test -Dtest=*BoundaryTest
- name: SAST (SonarQube)
run: mvn -B sonar:sonar -Dsonar.host.url=${{ secrets.SONAR_URL }}
- name: Dependency vulnerability scan
uses: aquasecurity/trivy-action@master
with:
scan-type: 'fs'
scan-ref: '.'
severity: 'CRITICAL,HIGH'
- name: Build Docker image
run: docker build -t platform:${{ github.sha }} .
- name: Container scan
uses: aquasecurity/trivy-action@master
with:
image-ref: 'platform:${{ github.sha }}'
severity: 'CRITICAL,HIGH'
- name: Push image to ECR
if: github.ref == 'refs/heads/main'
run: |
aws ecr get-login-password | docker login --username AWS --password-stdin ${{ secrets.ECR }}
docker tag platform:${{ github.sha }} ${{ secrets.ECR }}/platform:${{ github.sha }}
docker push ${{ secrets.ECR }}/platform:${{ github.sha }}
  1. Pick or create a task in the work tracker (linked to a backlog epic).
  2. Branch from main: feature/{ticket-id}-short-description.
  3. Implement with tests.
  4. Self-review against the checklist above.
  5. Push and open PR with description: what / why / how to test.
  6. Reviewer assignment: at least one peer + one senior for any module-boundary change.
  7. CI must pass: unit + integration + linting + SAST + container scan.
  8. Merge via squash (clean main history).
  9. Auto-deploy to dev / staging on merge; production deploy via GitOps with approval.

For services in production:

  • Primary on-call for 1-week rotations.
  • Critical alert (P1) — response within 15 minutes, ack within 30 minutes.
  • Material alert (P2) — response within business hours; ack within 4 hours.
  • Cyber incident — RBI reporting within 6 hours per IT MD; treat as P0.
  • Runbook must exist for every alert; PRs that add alerts must include / update runbooks.