Published on

Kotlin Over Java, Micronaut Over Spring Boot: The Stack Behind Cogoport's Finance Platform

Authors

Today's my birthday and felt like the perfect day to start this article series. Last year in July, I got the go-ahead to build the business finance platform for Cogoport, a freight-tech company handling thousands of trade transactions daily. We were running on Sage ERP before. The scope was not small. Invoicing, a full GST tax engine, vendor invoice OCR, accounts receivable and payable, bank reconciliation, vendor payments, and dunning. We built it all from scratch in six months as a microservices system.

The first question on day one was not architecture. It was language and framework. In Indian enterprise fintech, that conversation usually lasts ten minutes before someone says "Spring Boot" and the meeting moves on. We didn't move on. We spent a full week on it. We landed on Kotlin and Micronaut, and I want to write down exactly why, including where we weren't sure.


Why we even questioned Java

Java was the default everywhere I'd worked before Cogoport. Spring Boot, Hibernate, Postgres, Kafka for anything event-driven. The ecosystem is huge. The hiring pool is well-understood. When things break, you can usually find a solution online.

Our reasons to look elsewhere were practical, not about preference.

We were building roughly ten microservices in parallel with a team still being put together. Every service had its own data model and its own integrations: GST portals, OCR vendors, bank payment networks, the core Cogoport freight platform. The domain was complex. Indian GST has five tax slabs, multiple compliance workflows (GSTR-1, GSTR-3B, e-invoicing under IRN/IRP), and strict correctness requirements. A wrong tax calculation is not a UI bug you fix on Monday morning.

Two things made us question Java and Spring Boot:

Too much boilerplate for the time we had. We had six months. Finance domain models are large: invoices, credit notes, debit notes, tax lines, HSN codes, payment terms, ledger entries, dunning schedules. Writing each of those as a Java POJO with getters, setters, equals, hashCode, and a builder was wasted time. Java 16+ records helped, but they weren't well supported in Spring Boot yet, and mixing records with Hibernate was messy.

Spring Boot starts too slowly for microservices on K8s. When you're running twelve services with autoscaling, an 8–12 second startup is a real problem. A pod that takes ten seconds to become healthy can't absorb a traffic spike that happened seven seconds ago.


Why Kotlin won

Kotlin's pitch is simple: it's Java, but without the parts that slow you down. After two years of writing Java, that's mostly true. Here's what it fixed for us:

Null safety at the type system level

Finance systems deal with missing data all the time. A vendor invoice might arrive without a GSTIN. A payment entry might have no bank reference until settlement. A dunning record might have no contact email. In Java, each of these is a potential NullPointerException. The only thing stopping it is developer discipline.

Kotlin makes nullability part of the type. A String cannot be null. A String? can. The compiler won't let you call .uppercase() on a String? without handling the null case. This sounds like a small thing until you're writing a tax engine that runs GST on hundreds of line items:

fun calculateGst(amount: BigDecimal, hsnCode: String): TaxBreakdown {
    val rate = taxRateRepository.findByHsn(hsnCode)
        ?: throw TaxRateNotFoundException("No GST rate configured for HSN: $hsnCode")

    val taxableAmount = amount.setScale(2, RoundingMode.HALF_UP)
    val cgst = (taxableAmount * rate.cgstRate).setScale(2, RoundingMode.HALF_UP)
    val sgst = (taxableAmount * rate.sgstRate).setScale(2, RoundingMode.HALF_UP)
    val igst = (taxableAmount * rate.igstRate).setScale(2, RoundingMode.HALF_UP)

    return TaxBreakdown(taxableAmount, cgst, sgst, igst, cgst + sgst + igst)
}

The ?: operator forces you to make a choice: handle the null or throw explicitly. In Java, you'd either check for null manually (which people skip under pressure) or use Optional, which is wordy and doesn't work well with Hibernate.

We had zero NullPointerExceptions in production across the whole platform. Kotlin doesn't deserve all the credit; we tested well too. But having null safety in the compiler helped a lot when we were moving fast with a small team.

Data classes cut out half the boilerplate

Finance domains have a lot of simple record types. Every invoice line item, tax result, and dunning entry is just a structured object.

In Java, a TaxLine is forty lines:

public class TaxLine {
    private final String hsnCode;
    private final BigDecimal taxableAmount;
    private final BigDecimal cgstRate;
    private final BigDecimal sgstRate;
    private final BigDecimal igstRate;
    private final BigDecimal cgstAmount;
    private final BigDecimal sgstAmount;
    private final BigDecimal igstAmount;

    public TaxLine(String hsnCode, BigDecimal taxableAmount, ...) { ... }
    public String getHsnCode() { return hsnCode; }
    // ... eight more getters
    public boolean equals(Object o) { ... }
    public int hashCode() { ... }
    public String toString() { ... }
}

In Kotlin:

data class TaxLine(
    val hsnCode: String,
    val taxableAmount: BigDecimal,
    val cgstRate: BigDecimal,
    val sgstRate: BigDecimal,
    val igstRate: BigDecimal,
    val cgstAmount: BigDecimal,
    val sgstAmount: BigDecimal,
    val igstAmount: BigDecimal,
    val cess: BigDecimal = BigDecimal.ZERO,
) {
    val totalTax: BigDecimal get() = cgstAmount + sgstAmount + igstAmount + cess
}

You get equals, hashCode, toString, copy, and destructuring for free. We used copy constantly to create updated versions of invoices without changing the original.

Sealed classes for financial state machines

Every entity in a finance system has a lifecycle. An invoice isn't just a status string. It moves through real states with real rules: you can't approve an already-paid invoice, you can't dispute a draft, you can't void a paid one without a credit note.

Kotlin's sealed classes are built for this:

sealed class InvoiceStatus {
    object Draft : InvoiceStatus()
    object UnderReview : InvoiceStatus()
    data class Approved(val approvedBy: String, val approvedAt: Instant) : InvoiceStatus()
    data class Disputed(val reason: String, val raisedAt: Instant) : InvoiceStatus()
    data class Paid(val paymentRef: String, val paidAt: Instant) : InvoiceStatus()
    object Voided : InvoiceStatus()
}

The when expression is where sealed classes pay off:

fun canApprove(invoice: Invoice): Boolean = when (invoice.status) {
    is InvoiceStatus.Draft -> false
    is InvoiceStatus.UnderReview -> true
    is InvoiceStatus.Approved -> false
    is InvoiceStatus.Disputed -> true   // re-approval after dispute resolution
    is InvoiceStatus.Paid -> false
    is InvoiceStatus.Voided -> false
}

If you add a new InvoiceStatus later and forget to handle it here, the code won't compile. That whole class of bug is just gone. Java 17+ sealed interfaces give you something similar, but we were on Java 11 at the time.

Coroutines for external API calls

The platform was integration-heavy. Every vendor invoice went through an OCR service. Every tax computation cross-checked the GST portal. Vendor payments went through bank APIs. Dunning emails hit SMTP.

We didn't just pick coroutines. We ran a POC first. We compared Kotlin coroutines against Java with Project Reactor. Same services, same external calls, same Postgres interaction, just different async models.

We load-tested with Grafana k6 at 100 concurrent users against a local mock OCR service with a fixed 200ms response latency:

ApproachThroughput (RPS)p50 latencyp99 latency
Kotlin coroutines860108ms238ms
Java + Project Reactor835114ms251ms
Java + blocking thread-per-request185520ms1,180ms

Coroutines and Reactor were within 3% on throughput. The real comparison was against blocking: 4.6x fewer requests per second at 5x the latency. Both async models give you the same concurrency benefit; the model doesn't matter much for performance.

Coroutines won on readability. The Reactor version of the vendor invoice processor was sixty lines of Mono.zip, flatMap, and onErrorResume. The coroutine version was twenty lines that looked like normal sequential code:

@Singleton
class VendorInvoiceProcessor(
    private val ocrClient: OcrClient,
    private val taxEngine: TaxEngine,
    private val invoiceRepository: InvoiceRepository,
) {
    suspend fun process(documentBytes: ByteArray, orgId: UUID): VendorInvoice = coroutineScope {
        // OCR extraction and org config fetch run concurrently
        val ocrDeferred = async { ocrClient.extract(documentBytes) }
        val orgConfigDeferred = async { invoiceRepository.findOrgConfig(orgId) }

        val extracted = ocrDeferred.await()
        val orgConfig = orgConfigDeferred.await()

        val taxLines = taxEngine.validate(extracted.lineItems, orgConfig.gstRegime)
        VendorInvoice.from(extracted, taxLines, orgId)
    }
}

coroutineScope handles the lifecycle: if one async block fails, the other gets cancelled. The OCR call took 2-3 seconds; the DB fetch was under 50ms. Running them in parallel cut average processing time by ~60%.

What we didn't realize at the start of the POC: choosing coroutines would rule out Spring Boot.


Why Micronaut won

Once we picked coroutines, the framework choice mostly picked itself. Spring Boot's MVC stack didn't support Kotlin coroutines at the time. WebFlux did, but using WebFlux meant going all-in on reactive: reactive repositories, reactive security, everything. We didn't want to deal with that across twelve services in six months. Spring Boot was out.

Micronaut had built-in coroutine support in both its HTTP and data layers. A suspend function just works on a controller method. No reactive types at the boundary, no extra wiring. That's what pushed us toward Micronaut.

The startup and memory story was a secondary benefit, meaningful but not the primary driver.

Picking Micronaut over Spring Boot is a real trade-off. Spring Boot has better docs, more answers on StackOverflow, and more ready-to-use integrations. If you choose Micronaut, you give some of that up.

When Spring Boot starts, it scans your classpath using reflection, finds your beans, and wires everything together at runtime. For a service with Hibernate, Spring Security, and Spring Data, this takes 8-15 seconds.

Micronaut does that same work at build time using an annotation processor. The JAR has all the wiring baked in. At runtime there's no reflection, no classpath scanning. It just starts.

The difference on our actual services:

An invoice service with Micronaut Data, Postgres via JDBC, Kafka producers, structured logging, and health endpoints:

MetricMicronaut (Native)Micronaut (JVM)Spring Boot (JVM)
Cold start~35ms340ms~9.5s
Idle RSS~45MB~85MB~270MB
Docker image size~68MB~185MB~220MB
Throughput at 50 VUs (RPS)1,0901,1401,080
p50 latency44ms42ms44ms
p99 latency102ms98ms104ms

Throughput was nearly the same across all three. All that time is spent waiting on Postgres anyway, not in framework code. Native binary gives up a little peak throughput (no JIT warmup) but gets you tiny images and near-instant startup. The p99 difference was just Postgres noise.

For one service, 9.5 seconds is tolerable. For twelve services with autoscaling, it's a real problem. New pods come up after the traffic spike is already over. With 340ms startup, pods were ready before the first wave passed.

Memory footprint per pod

Memory was the other difference. Spring Boot's reflection-heavy startup keeps a lot in the heap: class metadata, proxies, bean registry. Our Spring Boot reference version used 280MB at idle. The Micronaut JVM services used 80-110MB.

Across twelve services, that's 3.4GB baseline RAM vs 1.1GB. We ran the same workload on fewer nodes.

GraalVM native images

Because Micronaut wires dependencies at build time without runtime reflection, it works cleanly with GraalVM. Spring Boot can't do this without a lot of manual reflection configuration because its DI model relies on reflection. Micronaut's doesn't.

We compiled every service to a native binary with GraalVM and packaged them into Docker images. Images were ~68MB: just the binary and shared libs. No JVM, no JAR. The invoice service started in ~35ms. A pod that used to take 340ms to pass its readiness probe was ready almost instantly.

The build was straightforward with Micronaut's Gradle plugin:

FROM ghcr.io/graalvm/native-image:ol9-java17-22 AS builder
WORKDIR /app
COPY . .
RUN ./gradlew nativeCompile --no-daemon

FROM frolvlad/alpine-glibc:alpine-3.17
WORKDIR /app
COPY --from=builder /app/build/native/nativeCompile/invoice-service .
EXPOSE 8080
ENTRYPOINT ["./invoice-service"]

The nativeCompile task handled all of Micronaut's reflection config automatically. We only needed manual hints for a couple of third-party serialization classes:

// src/main/resources/META-INF/native-image/reflect-config.json
[
  {
    "name": "com.fasterxml.jackson.databind.ext.Java7SupportImpl",
    "allDeclaredConstructors": true,
    "allPublicMethods": true
  }
]

Cluster-wide, RAM dropped from ~1.1GB to ~550MB across twelve services. ECR pull time on a cold node dropped from 18-22 seconds to 6-8 seconds. Faster pulls plus faster startup made autoscaling actually responsive.

Micronaut Data: compile-time query validation

Spring Data JPA parses query method names at startup. A typo like findByOrgnizationId only fails when the app starts, not when you write it.

Micronaut Data catches these at compile time:

@JdbcRepository(dialect = Dialect.POSTGRES)
interface InvoiceRepository : CrudRepository<Invoice, UUID> {
    fun findByOrganizationIdAndStatus(orgId: UUID, status: String): List<Invoice>
    
    @Query("SELECT * FROM invoices WHERE organization_id = :orgId AND due_date < :dueDate AND status NOT IN ('PAID', 'VOIDED')")
    fun findOverdueInvoices(orgId: UUID, dueDate: LocalDate): List<Invoice>
    
    fun countByOrganizationIdAndStatus(orgId: UUID, status: String): Long
}

A bad method name fails the build. That sounds small, but in a fintech codebase where schemas change constantly, it caught real bugs during refactoring that would have been runtime errors in staging.

The annotation model felt familiar

Micronaut's annotations look almost exactly like Spring's, so the learning curve was low.

// This is Micronaut. It looks almost exactly like Spring.
@Controller("/api/v1/invoices")
class InvoiceController(private val invoiceService: InvoiceService) {

    @Post("/{orgId}")
    @Status(HttpStatus.CREATED)
    suspend fun createInvoice(
        @PathVariable orgId: UUID,
        @Body request: CreateInvoiceRequest,
    ): InvoiceResponse = invoiceService.create(orgId, request)

    @Get("/{orgId}/{invoiceId}")
    suspend fun getInvoice(
        @PathVariable orgId: UUID,
        @PathVariable invoiceId: UUID,
    ): InvoiceResponse = invoiceService.get(orgId, invoiceId)

    @Patch("/{orgId}/{invoiceId}/submit")
    suspend fun submitInvoice(
        @PathVariable orgId: UUID,
        @PathVariable invoiceId: UUID,
    ): InvoiceResponse = invoiceService.submit(orgId, invoiceId)
}

Developers who knew Spring were writing Micronaut controllers within a day. The differences felt like upgrades: constructor injection by default, no @Autowired, and suspend functions that just work.


What we got right

The domain modeling paid off. When we added PartiallyPaid four months in, the compiler told us every when expression that needed updating. Across twelve services, zero runtime errors from missing the new state. All caught at build time.

Native binaries simplified everything. Images under 70MB, 35ms startup, ~45MB idle RSS. Autoscaling was actually responsive. A cold node pulling images was almost as fast as a warm one.

Startup time stopped being a concern. We never had to tune readiness probe delays. During month-end spikes, Kubernetes scaled up pods and they were taking traffic within seconds.

Null safety held up. The first three weeks were hectic: deadline pressure, team still learning the domain. We had zero NPEs. We had other bugs, but not that one.


Where we hit friction

It wasn't all smooth.

Micronaut's ecosystem is smaller. AWS integrations (SQS, Secrets Manager, S3) were less polished than Spring Boot's. We had to write integration code that Spring Boot would have given us out of the box. The Kafka library sometimes lagged behind the Kafka client version we needed.

Coroutine debugging takes getting used to. Stack traces inside coroutines don't look like regular Java traces. Junior developers found them confusing at first. We spent an afternoon adding structured log fields for coroutine_id and service_method, which made traces readable in Datadog.

Micronaut Data doesn't cover everything. For complex queries like AR aging reports and multi-join reconciliation views, we dropped to raw JDBC with @Query. About 80% of our repository methods worked fine with derived queries; the rest needed hand-written SQL. Spring Data JPA has the same limit; we just hit it more often because finance queries are more complex than typical CRUD.


What we'd do the same

I'd choose Kotlin again for any domain where correctness matters. Having the compiler catch null errors and missing state transitions isn't optional in fintech. The data classes and sealed types were paying off within the first week.

I'd choose Micronaut again for a K8s-based microservices system where startup time and pod memory actually matter. For a single service or a small system without autoscaling, Spring Boot's ecosystem and documentation would probably win.

Honestly, our constraints drove the decision as much as the tools did: six months, twelve services, K8s with HPA, and a domain where getting things wrong has real consequences. Different constraints might have led to different choices.

We didn't pick Kotlin and Micronaut because they were new. We picked them because they gave us fewer runtime surprises, cheaper pods, and images small enough to pull quickly on a cold node. For a finance platform built in six months, that was enough.


The next articles in this series go into each subsystem: the GST tax engine, the vendor invoice OCR pipeline, the AR dunning engine, and reconciliation. Each had its own interesting decisions.