Implementing a CQRS Read-Write Separation with JPA Projections and Recoil State Management


The project began with a seemingly straightforward requirement: a collaborative configuration editor. Multiple users needed to view and modify a complex, nested configuration object simultaneously. The initial implementation was a standard Spring Boot CRUD service using JPA/Hibernate, with a React frontend. This monolithic approach, where the same rich domain entity was used for both writes and reads, created a cascade of performance and consistency problems that brought development to a halt.

Reads were the first bottleneck. Serializing the entire ConfigurationDocument entity, with its multiple @OneToMany and @ManyToMany relationships, for a simple list view was generating a flurry of N+1 queries. Even with JOIN FETCH, the payload was bloated and the queries were slow. On the write path, optimistic locking on the aggregate root led to frequent transaction rollbacks in high-concurrency scenarios. The frontend code became a tangled mess of state management, trying to keep a single, complex client-side object synchronized with the backend, leading to UI freezes and data inconsistencies. It was clear the initial architecture was fundamentally flawed for this use case. A radical separation of concerns was necessary.

This led us to adopt the Command Query Responsibility Segregation (CQRS) pattern. The core idea is simple: use a different model to update information than the model you use to read information. This isn’t just about different DTOs; it’s a full-stack architectural commitment.

The Command Model: Transactional Integrity with JPA

For the write side, or the “Command” model, the primary concern is enforcing business rules and guaranteeing data consistency. JPA/Hibernate, with its rich entity lifecycle management and transactional guarantees, remains an excellent tool for this. The mistake was using the same entities for reads.

Our command model centers around the ConfigurationDocument aggregate root. This is the only object that commands can operate on. It’s designed to be transactionally consistent and always represents a valid state.

Here is the core ConfigurationDocument entity. Note the constraints, the aggregate-owned ConfigurationProperty collection, and the use of a version field for optimistic locking. This is a pure write model.

// package com.example.cqrs.domain.write;

import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;

import java.time.Instant;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
import java.util.Objects;

@Entity
@Table(name = "configuration_documents")
public class ConfigurationDocument {

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

    @Version
    private Long version; // For optimistic locking

    @NotBlank
    @Size(min = 3, max = 100)
    @Column(nullable = false, unique = true)
    private String name;

    @OneToMany(mappedBy = "document", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
    private Set<ConfigurationProperty> properties = new HashSet<>();

    @CreationTimestamp
    @Column(updatable = false)
    private Instant createdAt;

    @UpdateTimestamp
    private Instant updatedAt;
    
    private String lastUpdatedBy;

    // Default constructor for JPA
    protected ConfigurationDocument() {}

    public ConfigurationDocument(String name, String createdBy) {
        this.name = name;
        this.lastUpdatedBy = createdBy;
    }

    // --- Business Logic within the Aggregate ---

    public void updateName(String newName, String updatedBy) {
        if (newName == null || newName.trim().isEmpty()) {
            throw new IllegalArgumentException("Document name cannot be blank.");
        }
        this.name = newName;
        this.lastUpdatedBy = updatedBy;
    }

    public void setProperty(String key, String value, String updatedBy) {
        // A common mistake is to expose the collection directly.
        // Instead, the aggregate controls modifications.
        ConfigurationProperty existingProp = this.properties.stream()
            .filter(p -> p.getPropertyKey().equals(key))
            .findFirst()
            .orElse(null);

        if (existingProp != null) {
            existingProp.updateValue(value);
        } else {
            this.properties.add(new ConfigurationProperty(this, key, value));
        }
        this.lastUpdatedBy = updatedBy;
    }

    public void removeProperty(String key, String updatedBy) {
        this.properties.removeIf(p -> p.getPropertyKey().equals(key));
        this.lastUpdatedBy = updatedBy;
    }

    // --- Getters ---
    
    public UUID getId() { return id; }
    public String getName() { return name; }
    public Set<ConfigurationProperty> getProperties() { return new HashSet<>(properties); } // Return a copy
    public Instant getCreatedAt() { return createdAt; }
    public Instant getUpdatedAt() { return updatedAt; }
    public String getLastUpdatedBy() { return lastUpdatedBy; }
    
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        ConfigurationDocument that = (ConfigurationDocument) o;
        return Objects.equals(id, that.id);
    }

    @Override
    public int hashCode() {
        return Objects.hash(id);
    }
}

The CommandService is where the work happens. It’s a standard Spring service, but its methods are designed around explicit commands, not generic save operations. Each method represents a specific user intent.

// package com.example.cqrs.application;

import com.example.cqrs.application.command.UpdateDocumentPropertyCommand;
import com.example.cqrs.domain.write.ConfigurationDocument;
import com.example.cqrs.domain.write.ConfigurationDocumentRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.retry.annotation.Retryable;
import org.springframework.orm.ObjectOptimisticLockingFailureException;

import java.util.UUID;

@Service
public class DocumentCommandService {

    private static final Logger log = LoggerFactory.getLogger(DocumentCommandService.class);

    private final ConfigurationDocumentRepository documentRepository;

    public DocumentCommandService(ConfigurationDocumentRepository documentRepository) {
        this.documentRepository = documentRepository;
    }

    @Transactional
    public UUID createDocument(String name, String createdBy) {
        log.info("Executing CreateDocumentCommand for name: {}", name);
        ConfigurationDocument newDoc = new ConfigurationDocument(name, createdBy);
        ConfigurationDocument savedDoc = documentRepository.save(newDoc);
        log.info("Successfully created document with ID: {}", savedDoc.getId());
        return savedDoc.getId();
    }
    
    // In a real-world project, retrying on optimistic lock exceptions is a common pattern,
    // but requires careful consideration of command idempotency.
    @Transactional
    @Retryable(value = ObjectOptimisticLockingFailureException.class, maxAttempts = 3)
    public void updateDocumentProperty(UpdateDocumentPropertyCommand command) {
        log.info("Executing UpdateDocumentPropertyCommand for docId: {}, key: {}", command.documentId(), command.key());
        
        // The core of the command pattern: load the aggregate, execute a method, save.
        // The transaction boundary ensures atomicity.
        ConfigurationDocument document = documentRepository.findById(command.documentId())
            .orElseThrow(() -> new DocumentNotFoundException("Document with ID " + command.documentId() + " not found."));
            
        document.setProperty(command.key(), command.value(), command.updatedBy());
        
        // The save is technically redundant if the entity is managed, but explicit is better than implicit.
        documentRepository.save(document);
        log.info("Successfully updated property '{}' for document ID: {}", command.key(), command.documentId());
    }
    
    // Command DTO record
    public record UpdateDocumentPropertyCommand(UUID documentId, String key, String value, String updatedBy) {}

    public static class DocumentNotFoundException extends RuntimeException {
        public DocumentNotFoundException(String message) {
            super(message);
        }
    }
}

The Query Model: Blazing Fast Reads with JPA Projections

The query side is where the architecture pivots. We completely abandoned using the ConfigurationDocument entity for reads. Instead, we created dedicated, flat DTOs (Data Transfer Objects) tailored to specific UI components. These are often called “Projections.”

For our list view, we need a lightweight object with just the ID, name, and modification timestamp.

// package com.example.cqrs.application.query;

import java.time.Instant;
import java.util.UUID;

// This is a record, a perfect fit for an immutable data carrier.
public record DocumentSummaryView(
    UUID id,
    String name,
    Instant updatedAt,
    String lastUpdatedBy
) {}

For the detailed editor view, we need the document metadata and all its properties.

// package com.example.cqrs.application.query;

import java.time.Instant;
import java.util.Map;
import java.util.UUID;

public record DocumentDetailView(
    UUID id,
    String name,
    Long version,
    Instant updatedAt,
    String lastUpdatedBy,
    Map<String, String> properties
) {}

The key is how we populate these DTOs. We leverage JPA constructor expressions in our query repository. This tells Hibernate to execute a query that selects only the necessary columns and to directly instantiate our DTOs, completely bypassing the entity lifecycle management, dirty checking, and the overhead of managing relationships. The performance gain is substantial.

// package com.example.cqrs.application.query;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;

import com.example.cqrs.domain.write.ConfigurationDocument; // Note: Repository is on the write entity
import java.util.List;
import java.util.Optional;
import java.util.UUID;

@Repository
public interface DocumentQueryRepository extends JpaRepository<ConfigurationDocument, UUID> {

    // Query for the lightweight summary view
    @Query("""
           SELECT new com.example.cqrs.application.query.DocumentSummaryView(
               d.id, d.name, d.updatedAt, d.lastUpdatedBy
           )
           FROM ConfigurationDocument d
           ORDER BY d.updatedAt DESC
           """)
    List<DocumentSummaryView> findAllSummaries();

    // This is a more complex query for the detail view.
    // It's a common misconception that projections can't handle collections.
    // A better approach for collections is a multi-step query to avoid Cartesian products.
    // However, for this example we'll focus on the simple read path.
    // A production-grade query service would fetch the document and then its properties separately.
    
    @Query("""
           SELECT new com.example.cqrs.application.query.DocumentDetailView(
               d.id, d.name, d.version, d.updatedAt, d.lastUpdatedBy,
               (SELECT CAST(MAP(p.propertyKey, p.propertyValue) AS java.util.Map) FROM ConfigurationProperty p WHERE p.document.id = d.id)
           )
           FROM ConfigurationDocument d
           WHERE d.id = :id
           """)
    Optional<DocumentDetailView> findDetailById(UUID id);
}

A dedicated QueryService exposes these projections. It has no transactional logic and is purely for data retrieval.

// package com.example.cqrs.application;

import com.example.cqrs.application.query.DocumentDetailView;
import com.example.cqrs.application.query.DocumentQueryRepository;
import com.example.cqrs.application.query.DocumentSummaryView;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

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

@Service
@Transactional(readOnly = true) // Mark the entire service as read-only for optimization
public class DocumentQueryService {

    private final DocumentQueryRepository queryRepository;

    public DocumentQueryService(DocumentQueryRepository queryRepository) {
        this.queryRepository = queryRepository;
    }

    public List<DocumentSummaryView> getAllDocumentSummaries() {
        return queryRepository.findAllSummaries();
    }

    public Optional<DocumentDetailView> getDocumentDetails(UUID id) {
        return queryRepository.findDetailById(id);
    }
}

The separation is visualized by the API controller. We have distinct endpoints for commands (POST, PUT) and queries (GET).

sequenceDiagram
    participant Client
    participant APIController
    participant CommandService
    participant QueryService
    participant Database

    Client->>+APIController: GET /api/documents
    APIController->>+QueryService: getAllDocumentSummaries()
    QueryService->>+Database: SELECT id, name, ... FROM configuration_documents
    Database-->>-QueryService: Returns DocumentSummaryView DTOs
    QueryService-->>-APIController: Returns DTO list
    APIController-->>-Client: 200 OK with DTO list

    Client->>+APIController: PUT /api/documents/{id}/properties
    Note right of APIController: Command Payload: { "key": "...", "value": "..." }
    APIController->>+CommandService: updateDocumentProperty(command)
    CommandService->>+Database: BEGIN TRANSACTION
    CommandService->>Database: SELECT ... FROM configuration_documents WHERE id = ? FOR UPDATE
    Note right of CommandService: Business logic & validation
    CommandService->>Database: UPDATE configuration_properties ...
    CommandService->>Database: COMMIT
    Database-->>-CommandService: Success
    CommandService-->>-APIController: Success
    APIController-->>-Client: 202 Accepted

The Frontend: Recoil State Aligned with CQRS

On the frontend, Recoil proved to be an ideal partner for this architecture. Its atomic nature allows us to create state that mirrors our query models perfectly.

We define an atom to hold the list of document summaries.

// src/state/documentAtoms.js
import { atom } from 'recoil';

// This atom holds the array of lightweight summary objects.
export const documentListState = atom({
  key: 'documentListState',
  default: [],
});

// A separate atom family to hold the detailed view for each document.
// This prevents re-rendering the entire list when one document's details change.
// The key is the document ID.
export const documentDetailState = atomFamily({
  key: 'documentDetailState',
  default: null, // Default to null until fetched
});

Recoil selectors are used to fetch data from our query endpoints. They are the frontend equivalent of our DocumentQueryService.

// src/state/documentSelectors.js
import { selector } from 'recoil';
import axios from 'axios'; // Our HTTP client

const API_BASE_URL = '/api/documents';

// This selector fetches the list of document summaries from our query endpoint.
// Components subscribing to this will automatically update when the data is fetched.
export const documentListQuery = selector({
  key: 'documentListQuery',
  get: async () => {
    try {
      const response = await axios.get(API_BASE_URL);
      return response.data; // This will be an array of DocumentSummaryView
    } catch (error) {
      console.error("Failed to fetch document list:", error);
      throw error; // Let Recoil's ErrorBoundary handle this
    }
  },
});

// A selector family to fetch the detailed view for a specific document by its ID.
export const documentDetailQuery = selectorFamily({
  key: 'documentDetailQuery',
  get: (documentId) => async () => {
    if (!documentId) return null;
    try {
      const response = await axios.get(`${API_BASE_URL}/${documentId}`);
      return response.data; // This is a DocumentDetailView object
    } catch (error) {
      console.error(`Failed to fetch document detail for ${documentId}:`, error);
      throw error;
    }
  },
});

The most critical part is handling updates. When a user makes a change, we dispatch a command. On success, we do not manually update the client-side state. This is a common pitfall that leads to inconsistencies. Instead, we simply tell Recoil that the underlying data for our queries is now stale and needs to be refetched.

This is achieved with a custom hook that encapsulates the command logic and query invalidation.

// src/hooks/useDocumentMutations.js
import { useRecoilCallback } from 'recoil';
import axios from 'axios';
import { documentListQuery, documentDetailQuery } from '../state/documentSelectors';

export const useDocumentMutations = () => {
  // useRecoilCallback gives us access to a 'snapshot' and tools to modify Recoil state
  // outside of a React component's render cycle.
  const updateProperty = useRecoilCallback(({ refresh }) => async (documentId, key, value) => {
    const commandPayload = {
      key,
      value,
      updatedBy: 'current-user', // In a real app, this would come from an auth context
    };

    try {
      // 1. Dispatch the Command to the backend write model
      await axios.put(`/api/documents/${documentId}/properties`, commandPayload);
      
      // 2. On success, invalidate the relevant query selectors.
      // Recoil will automatically re-run the 'get' functions of these selectors,
      // fetching fresh data from the query endpoints.
      refresh(documentDetailQuery(documentId));
      refresh(documentListQuery); // Also refresh the list view, as 'updatedAt' has changed.

    } catch (error) {
      console.error("Failed to update property:", error);
      // Here you would handle the error, perhaps showing a toast notification.
      throw error;
    }
  });

  return { updateProperty };
};

A React component can now use this hook to provide a seamless user experience. The component is only concerned with rendering the state provided by selectors and dispatching commands via the hook. It has no complex state-merging logic.

// src/components/DocumentEditor.js
import React, { useState } from 'react';
import { useRecoilValue } from 'recoil';
import { documentDetailQuery } from '../state/documentSelectors';
import { useDocumentMutations } from '../hooks/useDocumentMutations';

function DocumentEditor({ documentId }) {
  const document = useRecoilValue(documentDetailQuery(documentId));
  const { updateProperty } = useDocumentMutations();
  const [key, setKey] = useState('');
  const [value, setValue] = useState('');
  const [isSaving, setIsSaving] = useState(false);

  if (!document) {
    return <div>Loading document...</div>;
  }

  const handleSave = async (e) => {
    e.preventDefault();
    if (!key || !value || isSaving) return;
    setIsSaving(true);
    try {
      await updateProperty(documentId, key, value);
      setKey('');
      setValue('');
    } catch (error) {
      alert('Failed to save property. Please try again.');
    } finally {
      setIsSaving(false);
    }
  };

  return (
    <div>
      <h2>{document.name} (v{document.version})</h2>
      <p>Last updated by: {document.lastUpdatedBy} at {new Date(document.updatedAt).toLocaleString()}</p>
      
      {/* Render properties... */}
      <ul>
        {Object.entries(document.properties).map(([propKey, propValue]) => (
          <li key={propKey}><strong>{propKey}:</strong> {propValue}</li>
        ))}
      </ul>

      {/* Form to add/update a property */}
      <form onSubmit={handleSave}>
        <input type="text" value={key} onChange={e => setKey(e.target.value)} placeholder="Property Key" />
        <input type="text" value={value} onChange={e => setValue(e.target.value)} placeholder="Property Value" />
        <button type="submit" disabled={isSaving}>
          {isSaving ? 'Saving...' : 'Save Property'}
        </button>
      </form>
    </div>
  );
}

Automation and Schema Management with GitHub Actions

The separation of read and write models introduces an operational complexity: database schema management. As we evolve our query projections, we might need to add new tables, views, or indexes that are specific to the read model. Managing these changes manually is a recipe for disaster.

We integrated Liquibase for declarative schema migrations and automated the process using GitHub Actions. This ensures that every change to our models is accompanied by a corresponding migration script, and that this script is applied automatically and consistently across all environments.

Our pom.xml includes the Liquibase plugin:

<plugin>
    <groupId>org.liquibase</groupId>
    <artifactId>liquibase-maven-plugin</artifactId>
    <version>4.23.0</version>
    <configuration>
        <propertyFile>src/main/resources/liquibase.properties</propertyFile>
    </configuration>
</plugin>

A simple changelog file captures the initial schema creation:

<!-- src/main/resources/db/changelog/db.changelog-master.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
        xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
        http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">

    <changeSet id="1" author="architect">
        <createTable tableName="configuration_documents">
            <column name="id" type="UUID">
                <constraints primaryKey="true" nullable="false"/>
            </column>
            <column name="version" type="BIGINT"/>
            <column name="name" type="VARCHAR(100)">
                <constraints nullable="false" unique="true"/>
            </column>
            <column name="created_at" type="TIMESTAMP WITH TIME ZONE"/>
            <column name="updated_at" type="TIMESTAMP WITH TIME ZONE"/>
            <column name="last_updated_by" type="VARCHAR(255)"/>
        </createTable>
    </changeSet>

    <!-- More changesets for properties table, indexes, etc. -->

</databaseChangeLog>

The GitHub Actions workflow automates the build, test, and migration process. The key step is running liquibase:update before deploying the application artifact.

# .github/workflows/ci-cd.yml
name: Build, Test, and Deploy

on:
  push:
    branches: [ "main" ]

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    
    services:
      postgres:
        image: postgres:15
        env:
          POSTGRES_USER: user
          POSTGRES_PASSWORD: password
          POSTGRES_DB: cqrs_db
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    steps:
    - name: Checkout repository
      uses: actions/checkout@v3

    - name: Set up JDK 17
      uses: actions/setup-java@v3
      with:
        java-version: '17'
        distribution: 'temurin'
        cache: 'maven'

    - name: Build and run backend tests
      run: mvn -B verify --file pom.xml
      env:
          SPRING_DATASOURCE_URL: jdbc:postgresql://localhost:5432/cqrs_db
          SPRING_DATASOURCE_USERNAME: user
          SPRING_DATASOURCE_PASSWORD: password

    - name: Apply database migrations
      run: >
        mvn liquibase:update 
        -Dliquibase.url=jdbc:postgresql://localhost:5432/cqrs_db
        -Dliquibase.user=user
        -Dliquibase.password=password
      
    - name: Build Docker image and push (omitted for brevity)
      # ... steps to build and push the application image to a registry

    - name: Deploy to environment (omitted for brevity)
      # ... steps to deploy the new image

This CQRS architecture, while more complex upfront, solved our core issues. The write path is robust and secure, shielded by our aggregate. The read path is incredibly fast, serving only the data the UI needs. The frontend state management is clean and predictable, eliminating a whole class of bugs related to client-side data synchronization.

The primary trade-off is the potential for eventual consistency. In our current implementation, the read model is updated within the same transaction as the write model, so it’s strongly consistent. However, for systems at a larger scale, the query models are often updated asynchronously via an event bus. This introduces a small delay between a command being processed and the change being visible in queries. Our architecture is now positioned to evolve in that direction if needed, for instance, by replacing the direct query model update with an event publication, without requiring a rewrite of the command or frontend logic. Another lingering point is the complexity of joins in projections; for highly relational reads, it may become more efficient to maintain a denormalized read model in a separate data store, such as Elasticsearch or a document database, updated via change-data-capture (CDC) streams.


  TOC