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.