Last Updated: May 31, 2026 at 10:00

Micronaut Data JDBC and JPA: Database Access for Spring Boot Developers

Build PostgreSQL-backed Micronaut applications using Micronaut Data, Flyway migrations, repositories, and transactions — while understanding how Micronaut's compile-time approach differs from Spring Data

If you're coming from Spring Boot, database access is one of the first areas where Micronaut feels both familiar and surprisingly different. In this guide, you'll learn how to connect Micronaut to PostgreSQL, manage schema changes with Flyway, and build repositories using Micronaut Data JDBC and JPA. Along the way, you'll see how Micronaut's compile-time repository generation reduces startup overhead and catches many errors before your application ever runs.

Image

Two paths for relational database access

Micronaut gives you two distinct approaches, and understanding the tradeoffs upfront will help you pick the right one for your situation.

Micronaut Data JDBC sits on top of plain JDBC. Query generation happens entirely at compile time, startup overhead is minimal, and there is no Hibernate persistence context in play. Think of it as the Micronaut equivalent of Spring Data JDBC.

Micronaut Data JPA brings in Hibernate ORM. It gives you the full ORM feature set — lazy loading, a managed entity lifecycle, complex object graphs, and inheritance mapping. Think of it as the Micronaut equivalent of Spring Data JPA.

This article covers both, beginning with JDBC.

Why Micronaut Data feels different

Before looking at any code, this concept is worth understanding clearly, because it affects everything else.

In Spring, when you write an interface like ProductRepository extends JpaRepository<Product, Long>, Spring discovers that interface at startup, parses your method names at startup, and generates a proxy implementation at runtime. Mistakes in your query methods are caught when the application boots — or sometimes only when that method is first called.

In Micronaut, the annotation processor runs during compilation. When you write ProductRepository extends CrudRepository<Product, Long>, Micronaut generates the SQL and the repository implementation class before your application ever starts. If you write a method like findByNonExistentField(...), the build fails. Your IDE shows a compile error.

The practical consequences of this are significant. Startup is faster because there is nothing to discover or generate at runtime. Query mistakes are caught during ./gradlew build, not in production. The tradeoff is that the annotation processor must be configured correctly — more on that in the setup section.

Project setup

Dependencies

For Micronaut Data JDBC with PostgreSQL, your build.gradle should include the following:

dependencies {
// Micronaut Data JDBC
annotationProcessor("io.micronaut.data:micronaut-data-processor")
implementation("io.micronaut.data:micronaut-data-jdbc")

// JDBC connection pool
implementation("io.micronaut.sql:micronaut-jdbc-hikari")

// PostgreSQL driver
runtimeOnly("org.postgresql:postgresql")

// Flyway (9.0+ requires a database-specific module; use flyway-core for older versions)
implementation("io.micronaut.flyway:micronaut-flyway")
runtimeOnly("org.flywaydb:flyway-database-postgresql")

// Validation
annotationProcessor("io.micronaut.validation:micronaut-validation-processor")
implementation("io.micronaut.validation:micronaut-validation")
}

The critical detail here is micronaut-data-processor as an annotationProcessor, not just implementation. Without this, Micronaut Data cannot generate your repository implementations at build time. The application will fail to start with an unsatisfied bean error because no implementation exists for your repository interface. This is one of the most common early mistakes.

Data source configuration

In application.yml:

datasources:
default:
url: jdbc:postgresql://localhost:5432/mydb
username: ${DB_USERNAME:postgres}
password: ${DB_PASSWORD:secret}
driver-class-name: org.postgresql.Driver
dialect: POSTGRES

flyway:
datasources:
default:
enabled: true
locations: classpath:db/migration

The ${ENV_VAR:default} syntax reads an environment variable and falls back to the default value if it is not set — useful for local development without having to export every variable.

Flyway migrations

Flyway manages your schema evolution through versioned SQL scripts. Micronaut auto-runs Flyway on startup once you set flyway.datasources.default.enabled: true. No extra code is needed, which is the same behaviour as Spring Boot.

Place migration scripts in src/main/resources/db/migration/. The naming convention is strict:

V{version}__{description}.sql

The double underscore between the version number and description is required. Some examples:

V1__create_products_table.sql
V2__add_category_to_products.sql
V3__create_orders_table.sql

Version numbers must be unique and strictly increasing. Flyway tracks which scripts have already run in a flyway_schema_history table it manages itself. On each startup it applies any scripts it has not yet run, in version order.

Here is a typical first migration:

-- V1__create_products_table.sql
CREATE TABLE products (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT,
price NUMERIC(10, 2) NOT NULL,
category VARCHAR(100),
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);

The migration scripts themselves are identical to what you would write in a Spring Boot project — Flyway is framework-agnostic.

Micronaut Data JDBC

The entity

import io.micronaut.data.annotation.*;
import io.micronaut.serde.annotation.Serdeable;

import java.math.BigDecimal;
import java.time.LocalDateTime;

@MappedEntity("products")
@Serdeable
public class Product {

@Id
@GeneratedValue(GeneratedValue.Type.IDENTITY)
private Long id;

private String name;
private String description;
private BigDecimal price;
private String category;

@DateCreated
private LocalDateTime createdAt;

public Product() {}

public Product(String name, String description, BigDecimal price, String category) {
this.name = name;
this.description = description;
this.price = price;
this.category = category;
}

// getters and setters
}

The key annotation to notice is @MappedEntity instead of @Entity. This distinction matters beyond naming. @Entity carries Hibernate semantics — lazy loading, a managed entity lifecycle, and a first-level cache. @MappedEntity is a plain mapping with none of that overhead. There is no persistence context tracking your objects in the background.

Other annotations map directly to Spring equivalents: @DateCreated auto-populates on insert like Spring Auditing's @CreatedDate, and @DateUpdated auto-populates on update like @LastModifiedDate. If you need both timestamps, add @DateUpdated private LocalDateTime updatedAt; alongside createdAt — Micronaut handles the rest.

The repository

import io.micronaut.data.jdbc.annotation.JdbcRepository;
import io.micronaut.data.model.query.builder.sql.Dialect;
import io.micronaut.data.repository.CrudRepository;

import java.util.List;
import java.util.Optional;

@JdbcRepository(dialect = Dialect.POSTGRES)
public interface ProductRepository extends CrudRepository<Product, Long> {

List<Product> findByCategory(String category);

Optional<Product> findByName(String name);

List<Product> findByCategoryOrderByPriceAsc(String category);

List<Product> findByPriceLessThan(BigDecimal maxPrice);

boolean existsByName(String name);

long countByCategory(String category);
}

CrudRepository<T, ID> gives you save(), findById(), findAll(), deleteById(), and count() out of the box — same as Spring Data's JpaRepository.

The derived query methods are parsed and converted to SQL at compile time. If you write findByNonExistentField(...), the build fails rather than throwing an exception at runtime. The @JdbcRepository(dialect = Dialect.POSTGRES) annotation replaces the optional @Repository stereotype from Spring and is what triggers the annotation processor to generate the implementation. The dialect can often be auto-detected from your datasource configuration, making the parameter optional — but declaring it explicitly removes any ambiguity and is a good habit.

Pagination and sorting

Switch to PageableRepository and add a Pageable parameter:

import io.micronaut.data.model.Page;
import io.micronaut.data.model.Pageable;
import io.micronaut.data.repository.PageableRepository;

@JdbcRepository(dialect = Dialect.POSTGRES)
public interface ProductRepository extends PageableRepository<Product, Long> {

Page<Product> findByCategory(String category, Pageable pageable);
}

Using it in a service:

Pageable pageable = Pageable.from(0, 20, Sort.of(Sort.Order.asc("price")));
Page<Product> page = productRepository.findByCategory("electronics", pageable);

List<Product> products = page.getContent();
int totalPages = page.getTotalPages();
long totalItems = page.getTotalSize();

Custom queries with @Query

When derived method names cannot express the query you need, use @Query with named parameters:

import io.micronaut.data.annotation.Query;

@Query("SELECT * FROM products WHERE price BETWEEN :min AND :max ORDER BY price")
List<Product> findByPriceRange(BigDecimal min, BigDecimal max);

@Query("SELECT * FROM products WHERE LOWER(name) LIKE LOWER(:term) OR LOWER(description) LIKE LOWER(:term)")
List<Product> search(String term);

@Query("UPDATE products SET category = :newCategory WHERE category = :oldCategory")
long recategorise(String oldCategory, String newCategory);

Note that modifying queries should return long or int to get the number of affected rows, or void if you do not need that count.

Transactions

Annotate service methods with @Transactional from jakarta.transaction:

import jakarta.transaction.Transactional;
import jakarta.inject.Singleton;

@Singleton
public class OrderService {

private final OrderRepository orderRepository;
private final ProductRepository productRepository;

public OrderService(OrderRepository orderRepository,
ProductRepository productRepository) {
this.orderRepository = orderRepository;
this.productRepository = productRepository;
}

@Transactional
public Order placeOrder(CreateOrderRequest request) {
Order order = orderRepository.save(new Order(request.getCustomerId()));

for (OrderItemRequest item : request.getItems()) {
Product product = productRepository.findById(item.getProductId())
.orElseThrow(() -> new ProductNotFoundException(item.getProductId()));

orderRepository.addItem(order.getId(), product.getId(),
item.getQuantity(), product.getPrice());
}

return order;
}
}

Spring uses @Transactional from org.springframework.transaction.annotation. Micronaut uses the standard jakarta.transaction.Transactional. The transaction semantics are largely familiar to Spring developers, but the implementation differs: Micronaut generates the transaction interception infrastructure at build time rather than relying on runtime proxy generation.

One important difference in rollback behaviour: jakarta.transaction.Transactional rolls back only on unchecked exceptions (RuntimeException and its subclasses). Checked exceptions do not trigger a rollback by default. Spring's @Transactional behaves the same way, but Spring developers who have used rollbackFor = Exception.class should be aware this needs to be applied explicitly here too if you want checked exceptions to roll back the transaction.

Relationships in Micronaut Data JDBC

One area that surprises Spring developers immediately is relationships. In Spring Data JPA you reach for @OneToMany, @ManyToOne, and @ManyToMany and Hibernate manages the rest. Micronaut Data JDBC handles relationships differently because it has no Hibernate persistence context.

Micronaut Data JDBC encourages aggregate-oriented design: each aggregate root manages its own data, and you fetch related data explicitly rather than relying on lazy loading. Associations are supported, but there is no automatic dirty checking or transparent proxying.

If your use case genuinely requires complex object graphs, automatic lazy loading, and full ORM behaviour, Micronaut Data JPA is the better fit — that module does bring in Hibernate and its familiar semantics.

Micronaut Data JPA

When you need full Hibernate ORM — complex object graphs, lazy loading, inheritance mapping — switch to the JPA module.

Dependencies

dependencies {
annotationProcessor("io.micronaut.data:micronaut-data-processor")
implementation("io.micronaut.data:micronaut-data-hibernate-jpa")
implementation("io.micronaut.sql:micronaut-jdbc-hikari")
runtimeOnly("org.postgresql:postgresql")
}

Configuration

datasources:
default:
url: jdbc:postgresql://localhost:5432/mydb
username: ${DB_USERNAME:postgres}
password: ${DB_PASSWORD:secret}

jpa:
default:
properties:
hibernate:
hbm2ddl:
auto: none
show_sql: false

Always set hbm2ddl.auto: none and let Flyway manage the schema. Never let Hibernate generate or modify your database structure.

The entity

import jakarta.persistence.*;
import java.math.BigDecimal;

@Entity
@Table(name = "products")
public class Product {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(nullable = false)
private String name;

private String description;

@Column(nullable = false)
private BigDecimal price;

private String category;

// constructors, getters, setters
}

Standard JPA annotations — identical to what you would write in Spring Data JPA.

The repository

import io.micronaut.data.annotation.Repository;
import io.micronaut.data.repository.CrudRepository;

@Repository
public interface ProductRepository extends CrudRepository<Product, Long> {

List<Product> findByCategory(String category);

@Query("SELECT p FROM Product p WHERE p.price < :maxPrice")
List<Product> findAffordable(BigDecimal maxPrice);
}

In Spring, @Repository is an optional stereotype. In Micronaut, @Repository is required to trigger the annotation processor to generate the implementation. Without it, you get an unsatisfied bean error at startup.

Spring to Micronaut quick reference

Entity annotation: Spring uses @Entity for JPA. Micronaut Data JDBC uses @MappedEntity. Micronaut Data JPA uses standard @Entity.

Primary key: Both use @Id and @GeneratedValue, with the same semantics.

Repository base interface: Spring uses JpaRepository<T, ID>. Both Micronaut modules use CrudRepository<T, ID>.

Repository trigger: In Spring, @Repository is optional. In Micronaut Data JDBC, use @JdbcRepository(dialect = ...). In Micronaut Data JPA, @Repository is required.

Query derivation: Spring parses method names at runtime. Micronaut parses them at compile time.

Custom queries: Spring's @Query accepts JPQL or SQL. Micronaut Data JDBC's @Query accepts SQL only. Micronaut Data JPA accepts JPQL or SQL.

Transactions: Spring uses org.springframework.transaction.annotation.Transactional. Micronaut uses jakarta.transaction.Transactional.

Auto-timestamps: Spring uses @CreatedDate with the Auditing module. Micronaut uses @DateCreated and @DateUpdated built in.

What to explore next

Micronaut Data R2DBC adds reactive, non-blocking database access for high-throughput services.

Multiple datasources covers connecting to more than one database in the same application.

Micronaut Cache adds @Cacheable on repository or service methods to cache query results in Redis or Caffeine.

Micronaut Security with data handles row-level filtering and tenant-aware datasource routing for multi-tenant applications.

N

About N Sharma

Lead Architect at StackAndSystem

N Sharma is a technologist with over 28 years of experience in software engineering, system architecture, and technology consulting. He holds a Bachelor’s degree in Engineering, a DBF, and an MBA. His work focuses on research-driven technology education—explaining software architecture, system design, and development practices through structured tutorials designed to help engineers build reliable, scalable systems.

Disclaimer

This article is for educational purposes only. Assistance from AI-powered generative tools was taken to format and improve language flow. While we strive for accuracy, this content may contain errors or omissions and should be independently verified.

Micronaut Data JDBC and JPA Explained for Spring Boot Developers