Tag: Hazelcast

  • Materialized Views for Fast Queries

    Part 3 in the “Building Event-Driven Microservices with Hazelcast” series


    Introduction

    We’ve established that events are the source of truth (Part 1) and built a Jet pipeline to process them (Part 2). But we’ve been dancing around a question that anyone who’s thought about event sourcing for more than five minutes will ask:

    If everything is stored as a sequence of events, how do you actually query anything?


    The Problem with Raw Events

    The naive answer is “replay them.” Want the current state of customer 123? Walk through every event that ever happened to customer 123 and apply them in order:

    public Customer getCustomer(String customerId) {
        List<Event> events = eventStore.getEventsForKey(customerId);
    
        Customer customer = null;
        for (Event event : events) {
            customer = event.apply(customer);
        }
        return customer;
    }
    

    This works. It’s also terrible. A customer with 10 events takes 10x longer to load than a brand-new customer with 1. A customer who’s been active for three years and has 1,000 events? Good luck serving that from an API endpoint with a latency SLA.

    You need O(1) lookups, not O(n) replays on every GET request.


    CQRS: The Pattern You Don’t Get to Opt Out Of

    This is where event sourcing stops being a standalone pattern and starts demanding an architectural partner.

    In a CRUD system, one table does everything. You INSERT into it, UPDATE it, and SELECT from it. The read model and the write model are the same thing — a row in a database. Simple.

    Event sourcing breaks that. Your write model is an append-only event log. Great for durability, great for auditing, terrible for queries. You can’t SELECT a customer’s current address from a log of every change that’s ever happened to them. Not efficiently, anyway.

    So you need a separate read model. A structure that’s optimized for the queries your application actually needs. This separation — commands go to the write model, queries go to the read model — is CQRS: Command Query Responsibility Segregation.

    We didn’t choose CQRS because it appeared on a list of architecture buzzwords. Event sourcing forced us into it. Once your source of truth is an event log, you need a separate read path. There’s no way around it.

    Now, CQRS is a general pattern. The read model could be a relational database you project events into for complex SQL queries. It could be Elasticsearch for full-text search, or a data warehouse for analytics. In our framework, we project into Hazelcast IMaps — materialized views that update in real-time as events flow through the Jet pipeline. The IMap gives us sub-millisecond lookups and keeps the read model co-located with the processing engine. No network hop, no separate database to manage.

    Materialized views are our implementation of the read side of CQRS.


    What is a Materialized View?

    A materialized view is a pre-computed projection. Instead of computing state on every query, you compute it once — when the event is processed in Stage 4 of the pipeline — and store the result. Queries just look up the stored result. In event sourcing literature, the component responsible for maintaining these projections is sometimes called a projection engine. In our framework, that’s the Jet pipeline from Part 2.

    Event replay O(n) vs materialized view O(1)

    The write path pays the cost once. Every subsequent read is free. The more reads you have per write — and most systems are read-heavy — the bigger the win.


    The ViewStore

    The HazelcastViewStore wraps an IMap:

    public class HazelcastViewStore<K> {
    
        private final IMap<K, GenericRecord> viewMap;
        private final String viewName;
    
        public HazelcastViewStore(HazelcastInstance hazelcast, String viewName) {
            this.viewName = viewName + "_VIEW";
            this.viewMap = hazelcast.getMap(this.viewName);
        }
    
        public Optional<GenericRecord> get(K key) {
            return Optional.ofNullable(viewMap.get(key));
        }
    
        public void put(K key, GenericRecord value) {
            viewMap.set(key, value);
        }
    
        public void remove(K key) {
            viewMap.delete(key);
        }
    
        public GenericRecord executeOnKey(K key,
                EntryProcessor<K, GenericRecord, GenericRecord> processor) {
            return viewMap.executeOnKey(key, processor);
        }
    }
    

    We use GenericRecord for the values — Hazelcast’s schema-flexible format that doesn’t require Java classes on the cluster. The executeOnKey method gives us atomic updates through Hazelcast’s EntryProcessor. As we discussed in Part 2, the EntryProcessor runs on the partition thread for that key — the same single-threaded-per-partition model that makes our pipeline ordering work. It reads current state, applies the change, and writes the result, all on one thread with no possibility of a concurrent modification. Atomic by architecture, not by locking.


    The ViewUpdater

    The ViewUpdater is the abstract class that each domain implements. It defines two things: how to extract the key from an event, and how to apply the event to produce new state.

    public abstract class ViewUpdater<K> implements Serializable {
    
        protected final transient HazelcastViewStore<K> viewStore;
    
        protected abstract K extractKey(GenericRecord eventRecord);
    
        protected abstract GenericRecord applyEvent(
            GenericRecord eventRecord,
            GenericRecord currentState);
    
        public GenericRecord updateDirect(GenericRecord eventRecord) {
            K key = extractKey(eventRecord);
            if (key == null) {
                logger.warn("Could not extract key from event");
                return null;
            }
    
            GenericRecord currentState = viewStore.get(key).orElse(null);
            GenericRecord updatedState = applyEvent(eventRecord, currentState);
    
            if (updatedState != null) {
                viewStore.put(key, updatedState);
            } else if (currentState != null) {
                viewStore.remove(key);
            }
    
            return updatedState;
        }
    }
    

    Returning null from applyEvent is the deletion convention. The updater removes the entry from the view. Everything else either creates a new entry or updates an existing one.


    Example: Customer View

    Here’s what a concrete implementation looks like:

    public class CustomerViewUpdater extends ViewUpdater<String> {
    
        public static final String VIEW_NAME = "Customer";
    
        public CustomerViewUpdater(HazelcastViewStore<String> viewStore) {
            super(viewStore);
        }
    
        @Override
        protected String extractKey(GenericRecord eventRecord) {
            return eventRecord.getString("key");
        }
    
        @Override
        protected GenericRecord applyEvent(GenericRecord event, GenericRecord current) {
            String eventType = getEventType(event);
    
            return switch (eventType) {
                case "CustomerCreated" -> createCustomer(event);
                case "CustomerUpdated" -> updateCustomer(event, current);
                case "CustomerStatusChanged" -> changeStatus(event, current);
                case "CustomerDeleted" -> null;
                default -> {
                    logger.debug("Unknown event type: {}", eventType);
                    yield current;
                }
            };
        }
    
        private GenericRecord createCustomer(GenericRecord event) {
            Instant now = Instant.now();
            return GenericRecordBuilder.compact(VIEW_NAME)
                .setString("customerId", event.getString("key"))
                .setString("email", event.getString("email"))
                .setString("name", event.getString("name"))
                .setString("address", event.getString("address"))
                .setString("phone", getStringField(event, "phone"))
                .setString("status", "ACTIVE")
                .setInt64("createdAt", now.toEpochMilli())
                .setInt64("updatedAt", now.toEpochMilli())
                .build();
        }
    
        private GenericRecord updateCustomer(GenericRecord event, GenericRecord current) {
            if (current == null) {
                logger.warn("Update event for non-existent customer: {}",
                    event.getString("key"));
                return null;
            }
    
            return GenericRecordBuilder.compact(VIEW_NAME)
                .setString("customerId", current.getString("customerId"))
                .setString("email", coalesce(event.getString("email"),
                                             current.getString("email")))
                .setString("name", coalesce(event.getString("name"),
                                            current.getString("name")))
                .setString("address", coalesce(event.getString("address"),
                                               current.getString("address")))
                .setString("phone", coalesce(getStringField(event, "phone"),
                                             current.getString("phone")))
                .setString("status", current.getString("status"))
                .setInt64("createdAt", current.getInt64("createdAt"))
                .setInt64("updatedAt", Instant.now().toEpochMilli())
                .build();
        }
    
        private GenericRecord changeStatus(GenericRecord event, GenericRecord current) {
            if (current == null) return null;
    
            return GenericRecordBuilder.compact(VIEW_NAME)
                .setString("customerId", current.getString("customerId"))
                .setString("email", current.getString("email"))
                .setString("name", current.getString("name"))
                .setString("address", current.getString("address"))
                .setString("phone", current.getString("phone"))
                .setString("status", event.getString("newStatus"))
                .setInt64("createdAt", current.getInt64("createdAt"))
                .setInt64("updatedAt", Instant.now().toEpochMilli())
                .build();
        }
    
        private String coalesce(String newValue, String currentValue) {
            return newValue != null ? newValue : currentValue;
        }
    }
    

    The coalesce pattern in updateCustomer handles partial updates — if the event only includes a new email but not a new name, we keep the existing name. The updatedAt timestamp always advances, which is useful for staleness checks later.


    Cross-Service Views: Denormalization Without the Guilt

    This is where materialized views get genuinely powerful.

    Consider displaying an order. The raw order data has a customer ID and product IDs, but no names, no emails, no SKUs. To show a complete order in the UI, you need data that lives in two other services.

    The traditional approach:

    Order order = orderRepository.findById(orderId);
    Customer customer = accountService.getCustomer(order.getCustomerId());  // HTTP call
    for (LineItem item : order.getItems()) {
        Product product = inventoryService.getProduct(item.getProductId());  // HTTP call
        item.setProductName(product.getName());
    }
    

    Three services involved at query time. If Account is slow, your order page is slow. If Inventory is down, your order page is broken. And you’ve just coupled three services together at runtime, which is exactly what microservices were supposed to prevent.

    With event sourcing, you build an enriched view that bakes in the data from other services at write time:

    public class EnrichedOrderViewUpdater extends ViewUpdater<String> {
    
        private final IMap<String, GenericRecord> customerView;
        private final IMap<String, GenericRecord> productView;
    
        @Override
        protected GenericRecord applyEvent(GenericRecord event, GenericRecord current) {
            String eventType = getEventType(event);
    
            if ("OrderCreated".equals(eventType)) {
                return createEnrichedOrder(event);
            }
            return current;
        }
    
        private GenericRecord createEnrichedOrder(GenericRecord event) {
            String customerId = event.getString("customerId");
    
            // Look up customer from local view — no HTTP call
            GenericRecord customer = customerView.get(customerId);
            String customerName = customer != null ?
                customer.getString("name") : "Unknown";
            String customerEmail = customer != null ?
                customer.getString("email") : "";
    
            List<GenericRecord> enrichedItems = new ArrayList<>();
            // ... iterate through items, look up products from productView
    
            return GenericRecordBuilder.compact("EnrichedOrder")
                .setString("orderId", event.getString("key"))
                .setString("customerId", customerId)
                .setString("customerName", customerName)
                .setString("customerEmail", customerEmail)
                .setArrayOfGenericRecord("lineItems",
                    enrichedItems.toArray(new GenericRecord[0]))
                .setString("status", "PENDING")
                .build();
        }
    }
    

    The result is a single IMap entry with everything you need:

    {
      "orderId": "order-123",
      "customerId": "cust-456",
      "customerName": "Alice Smith",
      "customerEmail": "alice@example.com",
      "lineItems": [
        {
          "productId": "prod-789",
          "productName": "Gaming Laptop",
          "sku": "LAPTOP-001",
          "quantity": 2,
          "unitPrice": 49.99
        }
      ],
      "status": "PENDING"
    }
    

    One lookup. Zero service calls. Works if Account and Inventory are both down for maintenance.

    If you’re from a relational database background, denormalization feels wrong — it violates third normal form, it duplicates data, your DBA would glare at you. But in an event-sourced system, the events are the normalized source of truth. Views are disposable projections. Denormalize all you want. If Alice changes her name, the CustomerUpdated event flows through, and you can update the enriched order view to reflect it. Or not, depending on whether you care — the order was placed when she was “Alice Smith,” and that’s what the event says.


    View Rebuilding

    One of the benefits we mentioned in Part 1 — and it’s worth seeing in practice. Found a bug in your view update logic? Fix the code, clear the view, replay the events:

    public <D extends DomainObject<K>, E extends DomainEvent<D, K>> long rebuild(
            EventStore<D, K, E> eventStore) {
    
        logger.info("Starting view rebuild for {} from {}",
            viewStore.getViewName(), eventStore.getStoreName());
    
        viewStore.clear();
    
        AtomicLong count = new AtomicLong(0);
        eventStore.replayAll(eventRecord -> {
            updateDirect(eventRecord);
            count.incrementAndGet();
        });
    
        logger.info("Rebuild complete. Processed {} events", count.get());
        return count.get();
    }
    

    Same mechanism handles schema migrations, new view types (write a new ViewUpdater, replay existing events — instant backfill), and disaster recovery. With CRUD, corrupted data is permanently corrupted. With event sourcing, the correct data is always recoverable from the event stream.


    View Patterns

    Not every view is a 1:1 entity projection. Here are the patterns we use:

    Entity View — one entry per domain object. Customer events produce one Customer view entry, keyed by customer ID. This is the default.

    Lookup View — index by an alternate key. A Customer-by-Email view maps email → customerId, so you can find a customer by email without scanning. When a customer changes their email, the old entry gets deleted and a new one created.

    Summary View — aggregate across entities. A Customer Order Summary view tracks customerId → { totalOrders, totalSpent }, incrementing on OrderCreated and decrementing on OrderCancelled.

    Enriched View — denormalization across services, as we just covered. Order events plus customer and product data produce a self-contained order view.

    Time-Series View — track changes over time. Daily inventory snapshots keyed by date + productId, updated by stock reservation and release events.

    You can maintain as many views as you need from the same event stream. They’re independent — a bug in one doesn’t affect the others, and each can be rebuilt separately.


    Handling View Staleness

    Views are updated asynchronously by the pipeline. There’s a brief window — usually measured in milliseconds — where the view might not reflect the very latest event. Three ways to deal with it:

    Accept it. For most reads, a few milliseconds of staleness is fine. Just read the view.

    Customer customer = customerView.get(customerId);
    

    Wait for it. When you need to read your own writes — like returning a newly created customer right after creating them — wait for the pipeline to complete:

    CompletableFuture<EventCompletion> future = controller.handleEvent(event, correlationId);
    future.join();
    
    // Now the view is guaranteed to reflect this event
    Customer customer = customerView.get(customerId);
    

    Check it. Include a version or timestamp and verify the view is current enough:

    long expectedVersion = event.getTimestamp().toEpochMilli();
    Customer customer = customerView.get(customerId);
    if (customer.getUpdatedAt() < expectedVersion) {
        // View not yet updated — wait or return the event data directly
    }
    

    Our framework’s handleEvent returns a CompletableFuture specifically to make the “wait for it” path easy. Most API endpoints use it.


    Performance

    Materialized view reads are IMap lookups. On a single laptop (M4 MacBook Pro, 16GB, Docker Compose):

    Operation Latency
    View read (local partition) < 0.1ms
    View read (remote partition) < 0.5ms
    View read (near cache) < 0.01ms
    View update (pipeline Stage 4) < 1ms

    For views that get read far more often than they're written — which is most of them — near cache is worth enabling:

    hazelcast:
      map:
        Customer_VIEW:
          near-cache:
            name: customer-near-cache
            time-to-live-seconds: 30
            max-idle-seconds: 10
            eviction:
              eviction-policy: LRU
              max-size-policy: ENTRY_COUNT
              size: 10000
    

    This caches view entries locally on the client side. Reads drop to microseconds. The TTL ensures stale entries get refreshed, and LRU eviction keeps memory bounded.


    Predefined Queries vs. Ad-Hoc Access

    Everything we’ve built so far serves predefined query patterns — get customer by ID, get enriched order, look up product stock. The services expose exactly the queries their API needs, and those queries are fast because the views are designed for them.

    Sooner or later, though, someone from the business side is going to want more. “Show me all orders over $500 in the last 30 days.” “Which customers changed their address this quarter?” Hazelcast supports SQL queries against IMaps — through Management Center, the command-line client, or third-party tools like DBeaver. The capability is there.

    But I wouldn’t be eager to have my production throughput impacted by someone’s exploratory query with a broad predicate scanning thousands of entries. An analyst’s ad-hoc join returning ten thousand rows is a very different workload than a targeted key lookup, and it can absolutely affect the latency your customers see.

    If you want to give analysts this kind of access — and there are good reasons to — consider WAN replication (a Hazelcast Enterprise feature) to duplicate your IMap data into a secondary cluster dedicated to analytics. Analysts query to their heart’s content; production stays untouched. This is really just CQRS taken one step further. We already separated the write model (events) from the operational read model (views). WAN replication separates the operational read model from the analytical read model. Same principle, another level of isolation.


    Practical Advice

    Keep your views focused. A CustomerView for profile queries, a CustomerByEmailView for authentication, a CustomerOrderSummary for order history. Don’t build a CustomerEverythingView that tries to serve every possible query pattern — it’ll be expensive to update and awkward to query.

    Document what events feed each view. When someone asks “why does the order view show the wrong customer name?” you want to be able to trace it: “The order view is updated by OrderCreated events, and it reads customer data from the Customer view at enrichment time. If the customer name changed after the order was created, the order view still shows the name at order time.”

    Handle missing data gracefully. When the enriched order view looks up a customer and gets null — maybe the customer was deleted, maybe the customer event hasn’t propagated yet — don’t crash. Use a sensible default:

    GenericRecord customer = customerView.get(customerId);
    String customerName = customer != null ?
        customer.getString("name") : "Customer " + customerId;
    

    And think about rebuild cost before your event history gets large. Replaying a million events to rebuild a view takes time. Strategies include periodic snapshots (save the view state and replay only from the snapshot forward), incremental rebuilds (track the last processed event sequence), and parallel rebuilds for independent views.


    That covers the read side of CQRS — how we turn an append-only event stream into fast, queryable state. The views are disposable, rebuildable, and independent. Denormalization is a feature, not a compromise. And the whole thing runs at IMap speed because the read model lives in the same platform as the processing engine.

    Next we’ll move from a single service to many. When a business operation has to update state in two or three different services and any one of them can fail, two-phase commit is off the table and you need a different answer. That’s the saga pattern.


    Next up: The Saga Pattern for Distributed Transactions

    Previous: Building the Event Pipeline with Hazelcast Jet

  • Baseball Invented Event Sourcing 150 Years Ago

    I’ve spent the last few months building event-sourced microservices on the Hazelcast platform — immutable event logs, materialized views, CQRS, the whole stack. If you’ve been following along, you’ve seen the why and the how. Event Sourcing is elegant. It’s powerful. And it was invented in the 1870s.

    Not by a software engineer. By a baseball scorekeeper.

    The Scorecard Is an Event Log

    Here’s the thing about keeping score at a baseball game. You don’t write down “the score is 3-2.” You write down what happened: Acuna singled to left. Olson doubled to right-center, Acuna to third. Riley grounded to short, Acuna scored, Olson to third. Play by play, pitch by pitch, every event appended in order to the record.

    Sound familiar? It should. That’s an event log. Immutable, append-only, temporally ordered. The scorekeeper’s pencil is the world’s oldest event producer.

    And here’s the part that made me sit up straight the first time I really thought about it: the scorekeeper never records the score directly. The score — along with batting averages, on-base percentages, pitcher stat lines, and everything else you see on the broadcast — is derived by replaying those events. You don’t store state. You compute it.

    If you read the Hazelcast framework piece, you’ll recognize this immediately. We spent considerable effort setting up event stores and materialized views as separate concerns — events flow in, projections flow out. The scorekeeper has been doing this with a pencil and a printed card since before the lightbulb was invented.

    Materialized Views Are Everywhere at the Ballpark

    Once you see this pattern, you can’t unsee it. Every summary artifact at a baseball game is a materialized view — a read-optimized projection of the same underlying event stream.

    The box score is a materialized view. It takes the full play-by-play event log and projects it into a compact batting line per player: at-bats, runs, hits, RBI. A completely different shape than the source data, optimized for a specific read pattern.

    The line score is a materialized view. Same events, different projection — runs per inning instead of runs per player.

    The pitcher’s stat line is a materialized view. IP, hits, walks, strikeouts, earned runs — all derived from the same at-bat events, filtered by which pitcher was on the mound.

    The standings page is a materialized view built from an even larger event stream — every game’s final result across the entire season.

    This is CQRS in its purest form. The write side is the scorekeeper recording events. The read side is every statistical summary, broadcast graphic, and newspaper box score derived from those events. Multiple consumers, each projecting the same stream into the shape they need.

    Where It Gets Really Interesting

    The conceptual alignment goes deeper than “it’s a log and you read from it.” The hard problems are the same too.

    Temporal queries. “What was the score after five innings?” In an event-sourced system, you replay events up to a point in time. In baseball, you read the line score up to the fifth column. Same operation. A scorekeeper can reconstruct the exact game state — who was on base, how many outs, what the count was — at any point in the game by replaying events to that moment.

    Corrections and compensating events. The official scorer initially rules a play an error, then changes it to a hit. The original event isn’t erased — it’s annotated, and the downstream materialized views (batting averages, fielding percentages) recompute. If you’ve ever dealt with event versioning or compensating events in a production system, this should feel very familiar. The scorer is solving the same problem you are, just with a pencil eraser instead of an event migration framework.

    MLB’s replay challenge system makes this even more explicit. The umpire calls ball three — that’s an event. The catcher challenges — that’s a compensating event. The replay review overturns the call to strike three — that’s the resolution event. You don’t go back and pretend the original call never happened. You record the whole sequence: the call, the challenge, the outcome. The materialized views — the count, the at-bat result, the pitcher’s stat line — all reflect the final state, but the event log preserves the full history of how you got there. That’s not an analogy for event sourcing. That is event sourcing.

    Multiple independent consumers. The home team’s scorer, the visiting team’s scorer, the press box statistician, and the broadcast team are all consuming the same stream of game events and maintaining their own projections. They might format them differently, update at slightly different times, even disagree on a ruling until the official scorer weighs in. Independent materialized views with eventual consistency.

    Eventual consistency itself. The press box stats might lag a play behind the action. The official scorer’s ruling on a borderline hit-vs-error might not come until minutes after the play — sometimes not until after the game. During that window, different consumers have different projections of the truth. The system is eventually consistent. Always has been. Nobody panics about it because the mental model is intuitive: the ruling will come, the stats will update, and the final record will be correct.

    Where the Analogy Breaks Down

    Now, I’m not going to pretend this maps perfectly. The places where it doesn’t map are actually instructive — they highlight what makes distributed event sourcing genuinely hard.

    Ordering is free in baseball. One batter at a time. One pitch at a time. Events are naturally, globally ordered with no effort. In a distributed system, you fight for ordering. You need Lamport clocks, vector clocks, consensus protocols, or a centralized sequencer. Baseball gets this for free because the game is inherently single-threaded. Your microservices are not.

    Single source of truth. Baseball has an official scorer — one authoritative human who makes the final call. Distributed systems don’t get a benevolent dictator. They have to negotiate consensus across nodes that might disagree, might be partitioned, might be lying. The official scorer never has a network partition. (Though I’ve seen some calls that made me wonder.)

    Bounded, finite streams. A baseball game ends. The event stream is finite, relatively small, and complete. Production event stores grow without bound, need compaction strategies, deal with schema evolution over years. A scorekeeper’s biggest scaling challenge is a 19-inning game. Yours is a few billion events with a 5-year retention policy.

    These gaps are worth noting because they’re exactly the problems that make event sourcing hard to implement in software. Baseball had the luxury of solving the easy version first — sequential, single-authority, bounded. We got the distributed, multi-authority, unbounded version. Lucky us.

    So I Built an App

    This pattern recognition wasn’t just an intellectual exercise. I’ve been building BaseballScorer, an iOS app for scoring baseball games by hand — the way scorekeepers have always done it, but on a tablet instead of paper.

    The app’s data model is, naturally, event-sourced. Pitches, plays, and substitutions are the events. The score, the scorecard, the line score, the inning summaries — all materialized views, derived by replaying the event stream. It’s the same architecture I’ve been writing about in the Hazelcast series, just running on a single iPad instead of a distributed cluster.

    It’s available on the App Store now. If you’re the kind of person who keeps score at games — or the kind of person who’s curious why anyone would — I think you’ll find it interesting.

    If you think this sounds interesting but aren’t familiar with keeping score, we’ve got you covered – check out this detailed how-to score site that covers using the app or paper scorecards if that’s more your style.

    And if you want to see what event sourcing looks like when you don’t have the luxury of a single-threaded, globally-ordered event stream, check out the Hazelcast microservices framework. Same patterns, much harder problem.


    This is part of an ongoing series on event-driven microservices at Nodes and Edges. Previous posts: Why Event-Driven Microservices? and The Hazelcast Microservices Framework.

  • The Hazelcast Microservices Framework

    How a side project connecting Event Sourcing to Hazelcast sat unfinished for years — and why I decided to bring it back with an AI collaborator.


    In my previous post, I shared some of my thinking about Event-Driven Microservices — the coupling problems, the mental shift toward thinking in events, and the patterns (Event Sourcing, CQRS, materialized views) that make it all work. That post was conceptual. This one is personal.

    I’ve been playing around with design concepts in this area for some time. While I was an employee of Hazelcast, I frequently worked with customers and prospects to show how Hazelcast Jet — an event stream processing engine built into the Hazelcast platform — could be used to build event processing solutions that would scale while continuing to provide low latency. These conversations were always framed around stream processing, though. Even when the intended use case was around microservices, we didn’t explicitly get into the Event Sourcing pattern. As someone coming from a background that was database-centric, the concept of events as the source of truth was a bit much for me.

    The Light Bulb Moment

    It was a light bulb moment when I realized that Hazelcast Jet could fit naturally into an Event Sourcing architecture — and that Hazelcast IMDG (the in-memory data grid, or caching layer) could concurrently maintain materialized views representing the current state of domain objects.

    Think about it: Event Sourcing needs an event log and a processing pipeline. Hazelcast Jet is a processing pipeline. CQRS needs a fast read-side store that’s kept in sync with the event stream. Hazelcast IMDG is a fast read-side store. Event Sourcing + CQRS maps beautifully onto Jet + IMDG (even though that acronym is officially retired — it’s all just “Hazelcast” now).

    And from there, I really wanted to demonstrate this. The original Microservices Framework project began.

    Version 1: The Proof of Concept

    The first version was focused on proving the core idea worked. Could I wire up a Hazelcast Jet pipeline to process domain events, persist them to an event store, and update materialized views — all in a way that was generic enough to work across different services?

    The answer was yes. The central pattern that emerged was straightforward: a service’s handleEvent() method writes incoming events to a PendingEvents map, which triggers a Jet pipeline that persists events to the EventStore, updates materialized views, and publishes to an event bus for other services to consume. It worked, and it was fast.

    Now, the central components of the architecture — the domain object, event class, controller, and pipeline — have survived relatively intact through multiple iterations of the implementation. The bones were good. But a lot of the specific implementation choices I made around those bones haven’t aged all that well.

    You know how it goes with side projects. Technical debt accumulates quietly, one “I’ll fix this later” at a time, until you’re looking at a codebase where you know you’d make different choices if you were starting over — but the sunk cost of time already invested keeps you from actually doing it. It’s the software equivalent of a kitchen renovation where you keep patching the old cabinets because ripping them out feels like too big a project for a weekend.

    That version of the framework is still hanging around on GitHub, although I decided not to link to it here as I may take it down at any time. (Upcoming posts will link to the improved version, so embedding links to the original will inevitably lead to someone grabbing the wrong one.)

    I got it to a working state, but there was a long list of things I wanted to add. Saga patterns for coordinating multi-service transactions. Observability dashboards. Comprehensive tests. Documentation that went beyond “read the code.” Each of these was a meaningful chunk of work, and progress slowed to a crawl.

    The Stall

    Let’s be honest about what happened: the project stalled. Not dramatically — it wasn’t ever really abandoned. It just… stopped moving. Every few months I’d open the codebase, when I had some extra time, and make a few minor, inconsequential changes while thinking of the more ambitious refactorings or added features that I’d get to when time permitted.

    If you’ve ever maintained a passion project alongside a day job, you know this feeling. The ideas don’t go away — they sit in the back of your mind, periodically surfacing with a pang of “I should really get back to that.” But the activation energy to restart is high, especially when the next step isn’t a fun new feature but the grind of scaffolding, configuration, and test coverage. So you close the laptop and tell yourself next month will be different. (It won’t be.)

    Enter AI-Assisted Development

    In early 2025, I started using Claude for various coding tasks and was genuinely surprised by the results. This wasn’t autocomplete on steroids — I could describe an architectural pattern and get back code that understood the why, not just the what. I could say “this needs to work like an event journal with replay capability” and get something that actually accounted for ordering guarantees and idempotency.

    That’s when the thought crystallized: what if I could use this to break through the stall?

    Here’s the thing — the stuff that had been blocking me wasn’t the hard design work. I knew what the architecture should look like. The bottleneck was the sheer volume of implementation grind: scaffolding new services, writing comprehensive tests, wiring up Docker configurations, producing documentation. Exactly the kind of work where you need focused hours, and a side project never has enough of those.

    Now, I want to be clear about what I mean here, because “AI wrote my code” carries a lot of baggage. This wasn’t about handing off the project and checking back in when it was done. It was about having a collaborator who could take high-level design direction and turn it into working code at a pace that made the project viable again. I’d provide the domain expertise, the architectural decisions, and the quality bar. The AI would provide the throughput.

    Making the Decision

    I decided to move forward with a clean reimplementation rather than trying to evolve the existing codebase. The core patterns from the original work — the Jet pipeline architecture, the event store design, the materialized view update strategy — were proven and would carry forward. But the project structure, package naming, dependency versions, and framework abstractions would start fresh. Sometimes the best way to fix a kitchen is to actually rip out the cabinets.

    The plan was to use Claude’s desktop interface for iterative design discussions (requirements, architecture, implementation planning) and then hand off to Claude Code for the actual coding. Design first, then build — with comprehensive documentation at every step so the AI would have rich context to work from.

    What happened next — the design phase, the handoff to Claude Code, and the surprises along the way — is the subject of the next post.