The Materialized View Pattern
Build a dedicated, pre-combined read model that merges events from several independent services into one instantly queryable record, removing the need to compose data across services at request time.
Learning objectives
- Explain what a materialized view is and how it differs from a single-service projection
- Identify when request-time composition across services becomes a bottleneck
- Build an event handler component that listens to events from multiple services and merges them into one row
- Recognize when a materialized view is unnecessary because a single projection already covers the need
◆ Imagine this
A gift shop could assemble a custom "welcome basket" from scratch every single time a customer orders one — walking the aisles, picking the chocolate, the card, the ribbon, each time a request comes in. Or the shop could pre-assemble a stock of ready-made baskets ahead of time, sitting on a shelf, ready to hand over the instant someone asks — built once, in advance, from ingredients gathered from all over the store.
A materialized view is that pre-assembled basket. Instead of visiting several services fresh for every single request that needs combined data, a materialized view is built ahead of time — one combined, ready-to-serve record, kept updated automatically as the underlying data changes — so a request for it becomes a single, instant lookup, with no assembly required at request time at all.
◆ The problem
An ordinary projection keeps one service's own read model in sync with its own events — an account service's balance view stays current by listening only to events its own aggregate produces. But a branch manager's "full customer profile" screen needs data spanning all four of a bank's services at once: customer details, total account balance, how many cards are issued, and how many loans are active. Neither a single service's own projection, which only knows about its own events, nor composing the answer fresh from four separate service calls at request time, gives a fast, pre-combined answer to that one specific, recurring question.
Composing the answer at request time — calling the customer service, then the account service, then the card service, then the loan service, and stitching the four responses together — works, but it means the screen's response time is bounded by the slowest of four network calls, and a partial failure in any one of them leaves the screen with incomplete data. A materialized view removes that dependency entirely by never doing this composition at request time in the first place.
The fix is a dedicated CustomerProfileView table, owned by its own small component, listening to events from all four services and assembling one combined record per customer. Answering the branch manager's full-profile question then becomes one simple, instant lookup against this one table — no cross-service calls at request time at all.
The flow looks like this: CustomerCreatedEvent, AccountOpenedEvent, CardIssuedEvent, and LoanApprovedEvent — four events, from four different aggregates, in four different microservices — all flow into one listener component. That listener merges each incoming event into a single, growing record per customer, and that record is exactly what gets served back the instant someone asks for a customer's profile. This is deliberate data duplication, the same idea explored anywhere data consistency and duplication trade-offs come up, finally put to concrete use: each service still owns its own data as the source of truth, and the materialized view is a deliberately duplicated, read-optimized copy, kept in sync only through events.
| Field | Comes from |
|---|---|
customerId, name, email | CustomerCreatedEvent (Customer service) |
totalAccountBalance | AccountOpenedEvent and any subsequent balance-changing events (Account service) |
cardCount | CardIssuedEvent (Card service) |
activeLoanCount | LoanApprovedEvent (Loan service) |
The entity is a plain, flat read model — nothing about it hints at the fact that its four fields came from four different services:
The listener is where the real, new idea shows up: a single component with @EventHandler methods for events published by entirely different aggregates, in entirely different microservices, all merging into one row.
◆ Under the hood — why this genuinely couldn't be one service's own projection
This is the real, structural difference from an ordinary projection: this listener subscribes to events published by four different aggregates, in four different microservices, not just one. A shared event-streaming backbone, like Axon Server, is exactly what makes this possible — since every service already publishes its own events through the same shared hub, any component, anywhere, can listen to any event it's interested in, entirely regardless of which service originally produced it. No direct coupling, no shared code, no API call between the services is required for this to work.
💻 Code example
@Entity public class CustomerProfileView { @Id private String customerId; private String name; private String email; private BigDecimal totalAccountBalance; private Integer cardCount; private Integer activeLoanCount; // getters/setters } @ProcessingGroup("customer-profile-view") @Component public class CustomerProfileViewProjection { private final CustomerProfileViewRepository repository; public CustomerProfileViewProjection(CustomerProfileViewRepository repository) { this.repository = repository; } @EventHandler public void on(CustomerCreatedEvent event) { CustomerProfileView view = new CustomerProfileView(); view.setCustomerId(event.getCustomerId()); view.setName(event.getName()); view.setEmail(event.getEmail()); view.setTotalAccountBalance(BigDecimal.ZERO); view.setCardCount(0); view.setActiveLoanCount(0); repository.save(view); } @EventHandler // published by the Accounts service, not Customer public void on(AccountOpenedEvent event) { CustomerProfileView view = repository.findById(event.getCustomerId()).orElseThrow(); view.setTotalAccountBalance(view.getTotalAccountBalance().add(event.getInitialBalance())); repository.save(view); } @EventHandler // published by the Cards service public void on(CardIssuedEvent event) { CustomerProfileView view = repository.findById(event.getCustomerId()).orElseThrow(); view.setCardCount(view.getCardCount() + 1); repository.save(view); } // ... and LoanApprovedEvent from the Loans service, following the same pattern }
▲ Common mistake — assuming a materialized view must live within one service
Assuming a materialized view can only be built inside one service is a real, common misunderstanding — its entire value comes specifically from combining events across service boundaries. If everything it needed already lived in one service, an ordinary projection would already be enough, and reaching for this pattern would just add unnecessary duplication.
▲ Edge case — what happens when events arrive out of order across services
A customer's account, card, and loan events don't share a single ordering guarantee with each other, since they originate from independent aggregates. If AccountOpenedEvent somehow arrives before CustomerCreatedEvent — unlikely in practice, since a customer typically must exist before an account can be opened, but not structurally impossible in every system — a handler like repository.findById(event.getCustomerId()).orElseThrow() would fail. Defensive handling, such as creating a placeholder row if one doesn't yet exist, is often worth adding once a materialized view spans several independently-timed services.
▲ Edge case — the view is a read-only copy, never a source of truth
Nothing should ever write directly to CustomerProfileView outside of this listener, and no command handler should ever read from it to make a business decision. It's a deliberately duplicated, eventually consistent copy for fast reads only — the account service's own data remains the actual source of truth for account balances, not this merged view.
Materialized views are a natural fit anywhere a UI screen or a report needs to combine data owned by several independent services into one fast response: a branch manager's customer-profile screen, a support agent's "everything about this customer" dashboard, or a nightly risk report that needs a customer's total exposure across accounts and loans at a glance.
They're also common in search and analytics use cases — a searchable order-history view that merges data from an orders service and a shipping service, or a recommendation engine's customer-summary table that pulls signals from several product areas. In every case, the shared theme is the same: a specific, recurring, cross-service question that's worth answering with a pre-built, always-current record instead of repeated, request-time composition.
- Q: What's the fundamental difference between an ordinary projection and a materialized view? A: A projection listens to one service's own events; a materialized view deliberately combines events published across several different services' aggregates into one unified read model.
- Q: What makes it possible for one listener to react to events from four completely separate microservices? A: Every service already publishes its events through the same shared event-streaming hub, so any component can subscribe to any event, regardless of its origin.
- Q: Should anything write directly to a materialized view outside of its listener? A: No — it's a read-only, deliberately duplicated copy kept in sync purely through events. The originating service's own data remains the actual source of truth.
- Q: When is a materialized view the wrong tool? A: When everything the read model needs already lives inside one service — an ordinary projection is simpler and sufficient in that case.
- Q: What's a practical risk when events from independently-timed services can arrive out of order relative to each other? A: A handler that assumes a prerequisite record already exists, such as looking up a customer row before it's been created, can fail — defensive handling is often needed once a view spans several independent services.
Want a visual for this concept?
Generate a diagram tailored to “The Materialized View Pattern” — the AI picks whichever visual (flowchart, comparison, sequence, etc.) best fits.
Sign in to generate a visual →