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.
Repository structure (recommended)
Section titled “Repository structure (recommended)”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.mdModule boundary enforcement
Section titled “Module boundary enforcement”Each module has its own package; inter-module calls must go through public APIs, not direct repository / entity access.
// Allowed@Servicepublic 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@Servicepublic 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..", "..." ) );}Naming conventions
Section titled “Naming conventions”| Element | Convention | Example |
|---|---|---|
| Java packages | lowercase, dot-separated | in.yourorg.platform.modules.disbursement |
| Java classes | PascalCase | DisbursementService, LoanAccount |
| Java methods | camelCase | createInstruction, markAsDisbursed |
| Constants | UPPER_SNAKE | MAX_RETRY_ATTEMPTS |
| Database tables / columns | snake_case | loan_account, current_outstanding |
| REST endpoints | kebab-case in path; resource plural | /v1/loan-accounts/{id} |
| JSON keys | snake_case (matching DB) | loan_account_id, current_outstanding |
| Event topics | dot-separated, lowercase | loan.activated, disbursement.executed |
| Workflow IDs | UUID + descriptive prefix | sanc-{uuid}, disb-{uuid} |
Spring Boot project conventions
Section titled “Spring Boot project conventions”Application configuration
Section titled “Application configuration”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 class skeleton
Section titled “Service class skeleton”@Service@RequiredArgsConstructor@Slf4j@Transactionalpublic 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; }
// ...}REST controller skeleton
Section titled “REST controller skeleton”@RestController@RequestMapping("/v1/disbursements")@RequiredArgsConstructor@Validatedpublic 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); }}Exception handling
Section titled “Exception handling”@RestControllerAdvicepublic 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)); }}Testing conventions
Section titled “Testing conventions”Unit tests (per service)
Section titled “Unit tests (per service)”@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); }}Integration tests (Testcontainers)
Section titled “Integration tests (Testcontainers)”@SpringBootTest@Testcontainers@AutoConfigureMockMvcclass 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")); }}Test data builders
Section titled “Test data builders”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(); }}Code review checklist
Section titled “Code review checklist”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-Keyhandling. - 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.
CI/CD pipeline
Section titled “CI/CD pipeline”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 }}Contribution flow
Section titled “Contribution flow”- Pick or create a task in the work tracker (linked to a backlog epic).
- Branch from
main:feature/{ticket-id}-short-description. - Implement with tests.
- Self-review against the checklist above.
- Push and open PR with description: what / why / how to test.
- Reviewer assignment: at least one peer + one senior for any module-boundary change.
- CI must pass: unit + integration + linting + SAST + container scan.
- Merge via squash (clean main history).
- Auto-deploy to dev / staging on merge; production deploy via GitOps with approval.
On-call expectations
Section titled “On-call expectations”For services in production:
- Primary on-call for 1-week rotations.
- Critical alert (P1) — response within
15 minutes, ack within30 minutes. - Material alert (P2) — response within business hours; ack within
4 hours. - Cyber incident — RBI reporting within
6 hoursper IT MD; treat as P0. - Runbook must exist for every alert; PRs that add alerts must include / update runbooks.
Sources
Section titled “Sources”- See 5.7 Security, IAM, audit and 14.5 Security and compliance for security expectations.
- See 2.13 IT and cybersecurity MD for regulatory expectations on SDLC.