CodeGym /Courses /Spring Data JPA /Read/write services and transactions

Read/write services and transactions

Spring Data JPA
Level 19, Lesson 3
Available

1. The service as the owner of intent

If your application has repositories, it is very easy to start treating services as a “thin wrapper around repository.save(...).” That is roughly like buying a microwave and using it only to make tea in it (technically possible, but life could clearly be richer). The service layer in our mini store exists not for architectural beauty, but to express the intent of the use case: “get the catalog page,” “change the price,” “place an order,” “reserve stock.”

A repository really does have its own transactional defaults, and that is a useful safety net. But it does not answer the main question: does this operation read, write, or combine several steps into one use case? The service answers that, because it is the service that owns the intent and the boundary of the operation.

In Spring, a transaction is almost always tied to the service operation. That makes sense: the transaction should cover the whole business operation, not a “random chunk” inside a repository or a “helper method with three lines.” So before talking about read/write services, we need to establish the basic idea: the service is the place where we decide whether an operation is a read or a change, and what transactional frame it should have.

A simple rule helps here, and at first it may seem too “simple,” but it saves you from chaos: if a method only reads, it should be shaped as a read use case; if a method changes data, it should be shaped as a write use case. And if a method does not touch the database at all and just calculates lineTotal, then it is just a method (and should not pretend to be transactional).

To avoid keeping this in your head as philosophy, let us immediately fix the difference in a table form. In our shop-data-jpa project, it is convenient in the end to have two types of services:

Question Query service (read) Command service (change)
What it does Gets data, builds a view/response Changes the system state (creates/updates/cancels)
Typical annotation @Transactional(readOnly = true) @Transactional
Inside Repository reads, sometimes several Read → checks → changes → write
How to name methods find..., get..., load... create..., change..., activate..., cancel...
Main risk accidentally “mix in” a write break all-or-nothing, split the transaction into pieces

And yes, it sounds banal. But in real projects it is precisely the lack of this banality that later produces a “2000-line service where half the methods write, half read, and the transactions are placed like stars in the sky — pretty, but who knows why.”

2. The transaction as a use-case frame

When we say “where does a transaction begin and end,” many people imagine something at the SQL level: BEGIN, COMMIT, ROLLBACK. In a Spring application, we usually do not write those by hand, but the meaning is still the same: we opened the transaction at the beginning of the use case and closed it at the end. And that is where the key engineering point appears: the transaction should begin where the use case began, not “somewhere inside it.”

To make this feel less like a slogan, imagine a normal call flow in our project:

flowchart TD
    A["Entry into use case
(public service method)"] --> B["Transaction started"] B --> C["Repository reads / checks"] C --> D["Business changes"] D --> E["Repository writes"] E --> F["Transaction finished
(commit/rollback)"]

The most typical place to start a transaction is a public Spring service method, because that is usually the “entry point” into the scenario. This also matches what we discussed earlier about the proxy boundary: Spring opens the transaction “around the call” to the service method.

At the same time, the service does not have to be large. On the contrary, the better you formulate the use case, the shorter the service method usually becomes. For example, “change a product price” is a normal standalone operation, while “change the price and status and category and also read a report nearby” is already a suspicious contraption.

Let us look at a typical write use case and highlight the boundary. What matters here is not the amount of code, but the fact that all the steps live inside one transaction:

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class PriceChangeService {

    @Transactional
    public void changePrice(Long productId) {
        // Public service method — the typical entry point of a use case.
        // That is why this is usually where the transaction should start.
        // Inside there will be read, checks, change, write.
    }
}

For now this looks like a “blank method,” but even at this stage it is useful: we have explicitly said that this is a change operation. Later we can safely fill it with entity loading, validation, and writing, without smearing the transaction across the entire codebase.

And here is an important thought about reading: a read use case can also be a service operation, and it can also have a transactional frame. Not because “that is the convention,” but because a read scenario may consist of several reads that need consistency, or simply because we want to clearly show intent in the code.

3. Read services: read-only transactions

The topic of read services is often underestimated: it seems like reading is “just call the repository and return the result.” But in a real project, reading very quickly turns into a “complex data glue,” especially when you want not the entity as a whole, but a neat read model (for example, ProductCatalogRow or OrderSummaryRow, which we used to make earlier through projections). So a read service is not “an extra layer,” but a place where we turn repository queries into a clear application scenario.

The simplest rule for read services is this: if a method expresses reading, it should be @Transactional(readOnly = true). That makes the code more readable and emphasizes intent. Plus, you will not accidentally “mix in” a write that later starts living its own life.

Example for a catalog: we want to get a page of active products as a ProductCatalogRow projection. Even if the repository inside already knows how to be read-only, the service fixes the use case and gives it a name that is pleasant to read.

import com.example.shopdatajpa.catalog.query.ProductCatalogRow;
import com.example.shopdatajpa.catalog.repository.ProductRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@Transactional(readOnly = true) // The whole service is about reading, so it is convenient to set readOnly on the class.
public class CatalogQueryService {

    private final ProductRepository productRepository;

    public CatalogQueryService(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    public Page<ProductCatalogRow> findActiveCatalog(Pageable pageable) {
        // Here we do not return an entity: we return a read model/projection suitable for the UI/response.
        return productRepository.findCatalogRowsByActiveTrue(pageable);
    }
}

Pay attention to the style: we set @Transactional(readOnly = true) on the class. This is convenient specifically for a query service, because most of its methods are reads. If one day a method appears that “suddenly changes data,” that will not be an accident, but a red light: either it is in the wrong place, or it needs to be annotated separately as write (and, most likely, moved into a command service).

Now a slightly more “real” example: a read use case that performs several related reads. Suppose we need a small widget-like view for a product: name, price, and available quantity. We do not have to do this as one super projection (though often we can); sometimes the service honestly glues together data from two repositories.

import com.example.shopdatajpa.catalog.entity.Product;
import com.example.shopdatajpa.catalog.repository.ProductRepository;
import com.example.shopdatajpa.inventory.repository.StockItemRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class ProductViewService {

    private final ProductRepository productRepository;
    private final StockItemRepository stockItemRepository;

    public ProductViewService(ProductRepository productRepository,
                              StockItemRepository stockItemRepository) {
        this.productRepository = productRepository;
        this.stockItemRepository = stockItemRepository;
    }

    @Transactional(readOnly = true)
    public int availableFor(Long productId) {
        // Important: this is a read use case, even if it makes several repository calls.
        Product p = productRepository.findById(productId).orElseThrow(); // If the product is missing, that is a scenario error.
        // Gluing the data together: the entity comes from catalog, stock comes from inventory.
        return stockItemRepository.findAvailableQuantityByProductId(p.getId());
    }
}

This code is intentionally simple so that the idea is visible: even reading can be an “operation,” and it has a boundary. We do not turn the repository into an orchestrator; we do this at the service level.

And one more nuance that is important to understand correctly. readOnly = true is a hint. It does not guarantee that “there will never be any writes.” It helps the infrastructure, shows intent to the team, and reduces the likelihood of an “accidental write,” but if inside a read method you start changing an entity and calling save, then you are your own enemy (Spring will not hold your hand; it is not a nanny). So a read service is discipline, not a magical prohibition.

4. Change services: read → validate → write

A write scenario usually looks like a small story. First we read the needed data from the database, then we check business conditions, then we change the state and write the result. That is exactly why a transaction is almost always needed: if you fail in the middle of the story, you do not want to leave the system in a “half-changed” state. For a mini store this is especially obvious: you cannot create an order and not decrease stock, and you cannot decrease stock and not create an order.

Let us start with a super typical case: changing the price. We want the service to read linearly: you can see the read, you can see the change, you can see the write step. That is why here we keep an explicit save() as a visual write point. This is not the whole JPA mechanism: a managed entity can synchronize with the database even without an explicit save(), but for this discussion it is more important first to see the use-case boundary, not the internals of the persistence context.

import com.example.shopdatajpa.catalog.entity.Product;
import com.example.shopdatajpa.catalog.repository.ProductRepository;
import java.math.BigDecimal;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class CatalogCommandService {

    private final ProductRepository productRepository;

    public CatalogCommandService(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    @Transactional
    public void changePrice(Long productId, BigDecimal newPrice) {
        // Step 1: load the needed entity within the transaction.
        Product product = productRepository.findById(productId).orElseThrow();

        // Step 2: apply the domain change (a business method in the entity).
        product.changePrice(newPrice);

        // Step 3: an explicit write point — so that it is easier for a beginner to read the scenario linearly.
        productRepository.save(product);
    }
}

What matters here is not the annotation, but the shape: this is a write use case, it is entirely inside a transaction, and it is not smeared across different classes.

Now a slightly more domain-driven example: activating a product. This is also essentially a “state change,” but people often mistakenly classify it as reading (“it is just a flag”). No: any data change is a write use case.

import com.example.shopdatajpa.catalog.entity.Product;
import com.example.shopdatajpa.catalog.repository.ProductRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class ProductLifecycleService {

    private final ProductRepository productRepository;

    public ProductLifecycleService(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    @Transactional
    public void activate(Long productId) {
        // Read the state...
        Product product = productRepository.findById(productId).orElseThrow();

        // ...change the state in the domain model...
        product.activate();

        // ...and persist the result in the database within the same transaction.
        productRepository.save(product);
    }
}

And here is another example that nicely shows “read + validate + write.” Reserving stock is not “just an update,” it is a business operation: we must check that there is enough product, and only then change the numbers.

import com.example.shopdatajpa.inventory.entity.StockItem;
import com.example.shopdatajpa.inventory.repository.StockItemRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class InventoryCommandService {

    private final StockItemRepository stockItemRepository;

    public InventoryCommandService(StockItemRepository stockItemRepository) {
        this.stockItemRepository = stockItemRepository;
    }

    @Transactional
    public void reserve(Long productId, int qty) {
        // 1) Read the stock record.
        StockItem stock = stockItemRepository.findByProductId(productId).orElseThrow();

        // 2) Change the state: inside there are checks for qty and availability.
        stock.reserve(qty);

        // 3) Save the changes: the whole operation must be atomic.
        stockItemRepository.save(stock);
    }
}

If you look at this and think “why is everything so similar?”, then you are exactly starting to see a healthy pattern. Write operations really are often similar: find, validate, change, save. That is normal.

And this is where the “maturity” of the service layer begins, without unnecessary pain: a write operation should not be a glue of random transactions. It should have one transactional frame that starts at the entry point. That is why @Transactional usually sits on the method that expresses the use case, not on tiny pieces.

5. Methods without database access

There is a funny trap with @Transactional: once you realize it matters, you may want to “coat everything that moves with transactions.” It is like discovering final for the first time and starting to put it even on variables in for (we have all been there, nothing to be ashamed of). But a transaction is only needed where there is data access and where you need to preserve atomicity/consistency.

If a method is pure math, string formatting, or constructing a value from parameters, then a transaction gives it nothing but noise. Worse, if you start opening transactions around “small helpers,” you risk getting strange boundaries, self-invocation effects, and simply losing track of where the database work actually happens.

The simplest example is calculating an order line total. There is no reason for a transaction here, because there is no interaction with the database.

import java.math.BigDecimal;

public class MoneyCalculator {

    public BigDecimal lineTotal(BigDecimal unitPrice, int quantity) {
        // Pure function: no repositories, no entities, no transaction.
        return unitPrice.multiply(BigDecimal.valueOf(quantity));
    }
}

Or, for example, generating an order number. This can be business logic, but not necessarily transactional. If your generator does not hit the database, it does not need a transaction.

import java.time.Instant;

public class OrderNumberGenerator {

    public String next() {
        // A simple “quick and dirty” example: it does not go to the database, so no transaction is needed.
        long ts = Instant.now().toEpochMilli();
        return "ORD-" + ts; // example: ORD-1710000000000
    }
}

Now an important point: helper methods are often called inside a @Transactional use case, and that is fine. The transaction “covers” the whole scenario from above, and helper methods work inside it as ordinary methods. But putting the annotation on a helper means blurring the boundary.

Even if a helper is in the same service and looks “logically related,” that does not make it the boundary of the use case. The use-case boundary is the entry point of the operation, not every line inside it.

6. The shop-data-jpa structure

When a project is small, you can survive with one CatalogService that both reads and writes. But our course is deliberately built so that you get used to a structure that does not collapse as it grows. So it is useful to build the habit now: separate reading and changing at the service level, not only at the annotation level.

In terms of our package-by-feature, this looks very natural. In catalog, you can keep two services: CatalogQueryService and CatalogCommandService. In inventory — InventoryQueryService and InventoryCommandService. In ordering — similarly, especially if you have both “get orders list” and “place order.”

Here are minimal “skeletons” — without extra details, just to see the structure and the boundaries:

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@Transactional(readOnly = true)
public class InventoryQueryService {
    // reads only: current availability, low-stock view, summary
}
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class InventoryCommandService {

    @Transactional
    public void reserve(Long productId, int qty) {
        // read -> checks -> change -> write
    }
}

In such a structure, you get a nice bonus: from the class name alone, you can already tell what kind of service it is. That reduces cognitive load, especially for beginners who have not yet learned to read annotations quickly with their eyes.

And here we connect neatly with the topic of repository transactional defaults from the previous lecture. Yes, repositories by default can be “slightly transactional.” But in a good project, the transactional policy is most readable at the service level, because the service reflects the use case. The repository should remain about data access, not scenarios.

If you want to see the “ideal picture” of boundaries, you can keep this mental slide in mind:

flowchart LR
    A["CatalogQueryService
readOnly"] --> B["ProductRepository
read methods"] C["CatalogCommandService
write tx"] --> B C --> D["StockItemRepository
write methods"]

This diagram is intentionally simple: it shows that the service is the owner of intent and the boundary, while repositories are tools.

7. Typical mistakes when working with services and transactions

Mistake #1: putting @Transactional on everything “just to make sure it works.”
That is how you usually end up with a service layer where every method is transactional, even if it only formats a string or calculates a sum. The problem is not so much performance as the fact that you stop understanding the boundaries of the use case. As a result, any read suddenly becomes expensive and strange, and the annotation turns into background noise that nobody reads anymore.

Mistake #2: mixing reading and changing in one method without explicit intent.
Classic example: a method is called getProduct(), but inside it “on the way” updates updatedAt, clears some fields, or fixes a status. It looks like a harmless optimization, but it breaks expectations: reading suddenly becomes writing. A month later someone will mark this method as readOnly = true, and then the “mystical” bugs begin (and mystical bugs are the most expensive, because you cannot get angry at them, you can only feel sad).

Mistake #3: trying to make the repository the owner of the business operation.
If you start writing repository methods that “read, validate, calculate the sum, and change stock,” then you lose the main point of layered architecture: the repository stops being data access, and the service becomes a decorative class. At that point, the transactional boundary is very hard to keep in one place, because the “business” spreads across a layer that was not meant for it.

Mistake #4: starting a transaction in a helper method because “it is important.”
A method’s importance is not the same as it being the boundary of the use case. The use-case boundary should be at the entry point of the scenario, where you can say, “we started the operation,” and at the end, “we either completed it successfully or rolled it back.” If the annotation ends up on a small internal method, while the outer method without a transaction makes other calls, you get boundaries that are hard to explain even to yourself.

Mistake #5: making a read service without readOnly and relying on repository defaults.
Technically it may work, especially if the read service makes a single repository call. But the educational and team value of readOnly = true is that you fix the intent. It is like naming a variable totalAmount instead of x: not because the compiler would otherwise fail to understand, but because people are not compilers, and they need help.

1
Task
Spring Data JPA, level 19, lesson 3
Locked
Separate query-service for reading active products
Separate query-service for reading active products
1
Task
Spring Data JPA, level 19, lesson 3
Locked
Command Service for Changing Product Price
Command Service for Changing Product Price
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION