Chapter 10: Java & Spring Boot: Properly Attired
The Java ecosystem approaches PostgreSQL materialized views with the formality one might expect — annotations, interfaces, configuration classes, and a migration framework. There are, by my count, five annotations required to properly map a materialized view to a JPA entity, two configuration annotations to enable scheduled refresh, and a migration file whose naming convention determines whether it executes once or on every change.
Beneath the ceremony, the integration is clean and the type safety is excellent.
This is the materialized views guide that Baeldung has not written. I intend it to be the one they link to.
The Entity
A materialized view maps to a JPA entity with two key annotations. @Entity is standard JPA — it declares the class as a persistent entity. @Immutable is Hibernate-specific — it tells Hibernate that this entity will never change, which allows Hibernate to skip dirty checking entirely. No snapshot comparison at flush time. No UPDATE statements generated. This is not merely correctness — it is a measurable performance optimization for read-heavy workloads where hundreds of materialized view rows might be loaded per request.
@Entity
@Immutable
@Table(name = "mv_dashboard")
public class DashboardMetrics {
@Id
@Column(name = "day")
private LocalDate day;
@Column(name = "events")
private Integer events;
@Column(name = "unique_users")
private Integer uniqueUsers;
@Column(name = "revenue")
private BigDecimal revenue;
protected DashboardMetrics() {} // JPA requires a no-arg constructor
public LocalDate getDay() { return day; }
public Integer getEvents() { return events; }
public Integer getUniqueUsers() { return uniqueUsers; }
public BigDecimal getRevenue() { return revenue; }
} No setters. The entity is read-only at the Java level. If any code attempts to modify a field and flush the session, Hibernate silently produces no SQL — @Immutable entities are excluded from all dirty checks. The absence of setters makes the intent explicit to every developer who reads the class: this object is not meant to be modified.
For materialized views with composite primary keys — common for aggregation views that are unique on a combination of date and category — use @IdClass:
public class DashboardMetricsId implements Serializable {
private LocalDate day;
private Long categoryId;
// equals() and hashCode() required
@Override
public boolean equals(Object o) { /* ... */ }
@Override
public int hashCode() { return Objects.hash(day, categoryId); }
}
@Entity
@Immutable
@IdClass(DashboardMetricsId.class)
@Table(name = "mv_dashboard")
public class DashboardMetrics {
@Id @Column(name = "day")
private LocalDate day;
@Id @Column(name = "category_id")
private Long categoryId;
@Column(name = "category_name")
private String categoryName;
@Column(name = "revenue")
private BigDecimal revenue;
// Getters only...
} The repository
public interface DashboardMetricsRepository
extends JpaRepository<DashboardMetrics, LocalDate> {
List<DashboardMetrics> findTop30ByOrderByDayDesc();
@Query("SELECT d FROM DashboardMetrics d WHERE d.day >= :since ORDER BY d.day DESC")
List<DashboardMetrics> findSince(@Param("since") LocalDate since);
@Query("SELECT SUM(d.revenue) FROM DashboardMetrics d WHERE d.day >= :since")
BigDecimal totalRevenueSince(@Param("since") LocalDate since);
} Standard Spring Data JPA. Derived queries, JPQL @Query methods, pagination with Pageable, sorting with Sort — everything works as expected. The repository does not know or care that mv_dashboard is a materialized view rather than a table. Spring generates the implementation at startup, Hibernate produces the SQL, PostgreSQL serves the pre-computed data.
For developers who want stricter compile-time enforcement — preventing write methods from even being available on the repository — Thorben Janssen recommends a custom base interface:
@NoRepositoryBean
public interface ReadOnlyRepository<T, ID> extends Repository<T, ID> {
Optional<T> findById(ID id);
List<T> findAll();
List<T> findAll(Sort sort);
Page<T> findAll(Pageable pageable);
}
public interface DashboardMetricsRepository
extends ReadOnlyRepository<DashboardMetrics, LocalDate> {
List<DashboardMetrics> findTop30ByOrderByDayDesc();
} This interface extends Repository rather than JpaRepository, exposing only read methods. No save(). No delete(). No saveAll(). A developer cannot accidentally call a write method because the method does not exist on the interface. This is compile-time prevention — stronger than @Immutable's runtime behavior of silently ignoring writes.
The Migration
Java has two major migration frameworks. Both handle materialized views well, but the approach differs.
Flyway: repeatable migrations (recommended)
Flyway's repeatable migrations — files prefixed with R__ instead of V followed by a version number — are the ideal fit for materialized views. The mechanism: Flyway tracks a checksum of the file's contents. When the checksum changes (because you've modified the SQL), Flyway re-executes the migration. No version number to manage. No separate "up" and "down" scripts. Edit the file, deploy, and Flyway handles the rest.
-- src/main/resources/db/migration/R__mv_dashboard.sql
DROP MATERIALIZED VIEW IF EXISTS mv_dashboard CASCADE;
CREATE MATERIALIZED VIEW mv_dashboard AS
SELECT
date_trunc('day', created_at)::date AS day,
COUNT(*)::integer AS events,
COUNT(DISTINCT user_id)::integer AS unique_users,
SUM(CASE WHEN event_type = 'purchase'
THEN amount ELSE 0 END)::numeric(13,2) AS revenue
FROM events
WHERE created_at >= CURRENT_DATE - INTERVAL '90 days'
GROUP BY 1
WITH DATA;
CREATE UNIQUE INDEX idx_mv_dashboard_day ON mv_dashboard (day);
CREATE INDEX idx_mv_dashboard_day_desc ON mv_dashboard (day DESC); This is the pattern I recommend for every materialized view in a Flyway-managed project. The reasoning: materialized views are DROP+CREATE objects, not ALTER objects. When the query changes, you drop the old view and create the new one. Flyway's repeatable migration does this automatically — you modify the SQL file, the checksum changes, Flyway re-runs it on the next migration. The DROP CASCADE at the top ensures a clean slate, and the indexes are recreated every time.
Repeatable migrations execute after all versioned migrations within a single migration run. This guarantees that your base tables (created by versioned migrations) exist before the materialized view attempts to query them.
Spring Boot auto-configures Flyway when flyway-core is on the classpath:
# application.yml — usually no configuration needed
spring:
flyway:
enabled: true
locations: classpath:db/migration Liquibase alternative
For teams using Liquibase, the runOnChange attribute provides equivalent behavior:
<changeSet id="mv-dashboard" author="waiter" runOnChange="true">
<sql>
DROP MATERIALIZED VIEW IF EXISTS mv_dashboard CASCADE;
CREATE MATERIALIZED VIEW mv_dashboard AS
SELECT
date_trunc('day', created_at)::date AS day,
COUNT(*)::integer AS events,
COUNT(DISTINCT user_id)::integer AS unique_users,
SUM(CASE WHEN event_type = 'purchase'
THEN amount ELSE 0 END)::numeric(13,2) AS revenue
FROM events
WHERE created_at >= CURRENT_DATE - INTERVAL '90 days'
GROUP BY 1
WITH DATA;
CREATE UNIQUE INDEX idx_mv_dashboard_day ON mv_dashboard (day);
</sql>
</changeSet> runOnChange="true" re-executes the changeset when its content changes — functionally identical to Flyway's R__ behavior. The changeset ID remains the same; Liquibase tracks the checksum and detects modifications.
Scheduled Refresh
@Service
public class MaterializedViewRefreshService {
private static final Logger log = LoggerFactory.getLogger(
MaterializedViewRefreshService.class);
private final JdbcTemplate jdbcTemplate;
public MaterializedViewRefreshService(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@Async
@Scheduled(fixedRate = 900_000) // every 15 minutes
public void refreshDashboard() {
try {
jdbcTemplate.execute(
"REFRESH MATERIALIZED VIEW CONCURRENTLY mv_dashboard"
);
log.info("Dashboard MV refreshed successfully");
} catch (Exception e) {
log.error("Dashboard MV refresh failed", e);
}
}
} Two annotations carry the weight. @Scheduled(fixedRate = 900_000) fires the method every 900,000 milliseconds — 15 minutes. @Async executes the method on a separate thread from Spring's task executor pool, which is critically important: without @Async, the refresh runs on the scheduler thread itself, and if it takes 30 seconds, no other @Scheduled methods fire during that window. With @Async, the scheduler dispatches the task and immediately returns to managing the schedule.
Both require configuration annotations on your application class:
@SpringBootApplication
@EnableScheduling
@EnableAsync
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
} Without @EnableAsync, the @Async annotation is silently ignored — a behavior that has, in my observation, surprised more than a few production deployments. The method runs synchronously on the scheduler thread with no warning. Always verify both annotations are present.
Multi-instance deployments
When running multiple application instances — Kubernetes pods, ECS tasks, multiple servers behind a load balancer — each instance schedules the same refresh. Without coordination, they all attempt to refresh simultaneously. The advisory lock pattern prevents this:
@Autowired
private DataSource dataSource;
@Async
@Scheduled(fixedRate = 900_000)
public void refreshDashboard() {
try (Connection conn = dataSource.getConnection();
Statement stmt = conn.createStatement()) {
ResultSet rs = stmt.executeQuery(
"SELECT pg_try_advisory_lock(hashtext('mv_dashboard'))");
rs.next();
if (!rs.getBoolean(1)) {
log.debug("Another instance is refreshing, skipping");
return;
}
try {
stmt.execute(
"REFRESH MATERIALIZED VIEW CONCURRENTLY mv_dashboard");
log.info("Dashboard MV refreshed");
} catch (Exception e) {
log.error("Dashboard MV refresh failed", e);
} finally {
stmt.execute(
"SELECT pg_advisory_unlock(hashtext('mv_dashboard'))");
}
} catch (SQLException e) {
log.error("Could not acquire connection for refresh", e);
}
} Session-level advisory locks are bound to the database connection, so the lock, the refresh, and the unlock must execute on the same one. DataSource.getConnection() ensures this — JdbcTemplate acquires and releases connections per call, which would leave the lock orphaned. pg_try_advisory_lock returns immediately — true if acquired, false if another session holds it. No blocking. No waiting.
As with every framework chapter: if the refresh requires no Java logic — no conditional checks, no error routing to your monitoring system, no integration with deployment pipelines — pg_cron is the simpler alternative. Three lines of SQL inside PostgreSQL. No Spring code. No annotations. No configuration classes.
Production Operations
HikariCP pool sizing
Spring Boot uses HikariCP as its default connection pool. The defaults are sensible for most applications, but materialized view refresh introduces a consideration: the REFRESH operation holds a database connection for the duration of the refresh. If your materialized view takes 30 seconds to refresh and your pool has 10 connections, that's one connection unavailable for application queries for 30 seconds.
spring:
datasource:
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000 Start with maximum-pool-size at twice the CPU core count of your PostgreSQL server. A 4-core database server: pool size of 8 to 10. Monitor with Spring Actuator:
management:
endpoints:
web:
exposure:
include: health,metrics The metrics hikaricp.connections.active, hikaricp.connections.idle, and hikaricp.connections.pending tell you whether the pool is correctly sized. If pending is regularly above zero, your pool is too small. If idle is consistently near maximum-pool-size, you have more connections than you need.
Integration testing with Testcontainers
Materialized views require a real PostgreSQL instance for testing. H2 — the in-memory database commonly used in Spring Boot test suites — does not support materialized views. There is no workaround for this.
@DataJpaTest
@Testcontainers
@AutoConfigureTestDatabase(replace = Replace.NONE)
class DashboardMetricsRepositoryTest {
@Container
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:17");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired
private DashboardMetricsRepository repository;
@Autowired
private JdbcTemplate jdbcTemplate;
@Test
void shouldReturnRefreshedMetrics() {
// Insert test data into source tables
jdbcTemplate.execute(
"INSERT INTO events (user_id, event_type, amount, created_at) " +
"VALUES (1, 'purchase', 49.99, NOW()), " +
"(2, 'purchase', 29.99, NOW()), " +
"(1, 'pageview', 0, NOW())"
);
// Refresh the materialized view
jdbcTemplate.execute("REFRESH MATERIALIZED VIEW mv_dashboard");
// Verify
var metrics = repository.findTop30ByOrderByDayDesc();
assertThat(metrics).isNotEmpty();
assertThat(metrics.get(0).getEvents()).isEqualTo(3);
assertThat(metrics.get(0).getUniqueUsers()).isEqualTo(2);
assertThat(metrics.get(0).getRevenue())
.isEqualByComparingTo(new BigDecimal("79.98"));
}
} @AutoConfigureTestDatabase(replace = Replace.NONE) is essential — it tells Spring Boot's test auto-configuration to use the real database (provided by Testcontainers) instead of replacing it with H2. Without this annotation, Spring substitutes H2, the materialized view doesn't exist, and your tests fail with confusing "relation does not exist" errors.
@DynamicPropertySource injects the Testcontainers-managed PostgreSQL connection details into Spring's configuration at test startup. Flyway runs automatically — the R__ migration creates the materialized view before any test method executes.
For CI environments where Docker is available (GitHub Actions, GitLab CI, Jenkins with Docker), Testcontainers works out of the box. For environments where Docker is not available, use a dedicated PostgreSQL service in your CI pipeline and configure the connection properties via environment variables.
The Java ecosystem's verbosity is, I confess, more than I personally prefer. Five annotations on the entity class. Two on the application class. A migration framework with its own naming conventions and execution semantics. A connection pool with a dozen configuration parameters.
But the type safety is genuine — the compiler catches errors that would be runtime surprises in dynamically typed languages. The migration tooling is mature — Flyway has been managing database schemas in production for over a decade. The scheduled refresh integrates naturally with Spring's existing infrastructure — no additional libraries, no external services, no Redis.
The materialized view neither knows nor cares that it is being accessed through three layers of abstraction. It serves data at index-scan speed regardless.
In the next chapter, we attend to PHP and Laravel — where Eloquent's simplicity provides a refreshing contrast to what we've just navigated.