Zum Inhalt springen
Java

readOnly, @Repository, @Modifying: qué hacen realmente las anotaciones en un repositorio de Spring Data

Una interfaz de repositorio rara vez lleva más de tres anotaciones y, aun así, ellas deciden si tu cambio llega alguna vez a la base de datos. @Transactional(readOnly = true), @Repository y @Modifying parecen declaraciones para el siguiente lector. Dos de ellas actúan a fondo sobre Hibernate, una no hace absolutamente nada.

Este artículo recorre todo el camino con un único ejemplo continuo: un trabajo nocturno que debe finalizar las promociones caducadas y que cada mañana informa de que todo fue bien, mientras que la tienda sigue mostrando el precio de promoción. Al final queda una solución que aguanta, más la respuesta honesta sobre cuándo no compensa todo ese esfuerzo.

Aquí se trata exclusivamente de las anotaciones en la interfaz del repositorio. Las trampas clásicas del proxy de @Transactional (autoinvocación, excepciones comprobadas, LazyInitializationException) son un tema propio y están en @Transactional en Spring: el proxy y sus trampas.

Contenido

La escena: el trabajo que no hace nada

Una tienda en línea vende herramientas: taladros, sierras de calar, atornilladores a batería, además de accesorios y repuestos. Las promociones tienen plazo. Cada promoción cuelga de un número de artículo y tiene una fecha de fin, más un indicador de si sigue vigente. A las tres y cuarto de la madrugada se ejecuta un trabajo que finaliza las promociones caducadas para que por la mañana el atornillador vuelva a su precio habitual. La lógica de negocio es modesta y la entidad, en consecuencia, pequeña.

@Entity
public class PromoPrice {

    @Id
    @GeneratedValue
    private Long id;

    private String articleNumber;

    private LocalDate endsOn;

    private boolean running;

    protected PromoPrice() {
    }

    public boolean isRunning() {
        return running;
    }

    public void setRunning( boolean running ) {
        this.running = running;
    }

    public LocalDate getEndsOn() {
        return endsOn;
    }

    public String getArticleNumber() {
        return articleNumber;
    }
}

El repositorio tiene el aspecto que tienen los repositorios en muchos proyectos. Tres anotaciones, todas puestas con buena conciencia.

@Repository
@Transactional(readOnly = true)
public interface PromoPriceRepository extends JpaRepository<PromoPrice, Long> {

    List<PromoPrice> findByRunningTrueAndEndsOnBefore( LocalDate cutoff );
}

El servicio, igual. La clase lleva readOnly = true porque es el valor por defecto del proyecto para los servicios y ya nadie se lo plantea.

@Service
@Transactional(readOnly = true)
public class PriceMaintenance {

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

    private final PromoPriceRepository promoPrices;

    public PriceMaintenance( PromoPriceRepository promoPrices ) {
        this.promoPrices = promoPrices;
    }

    @Scheduled(cron = "0 15 3 * * *")
    public void endExpiredPromotions() {
        List<PromoPrice> expired = promoPrices.findByRunningTrueAndEndsOnBefore( LocalDate.now() );

        for ( PromoPrice promo : expired ) {
            promo.setRunning( false );
        }

        log.info( "{} promociones caducadas finalizadas", expired.size() );
    }
}

A las 3:15 el registro dice exactamente lo que debe decir:

2026-08-18 03:15:02  INFO  PriceMaintenance : 412 promociones caducadas finalizadas

En la base de datos siguen vigentes 412 promociones. Ningún error, ninguna traza, ningún rollback en el registro. La aplicación está convencida de haber hecho su trabajo y la tienda sigue vendiendo al precio de promoción.

Requisitos y versiones

Todo en este artículo se refiere a Spring Boot 4 con Spring Data JPA 4 e Hibernate como proveedor de persistencia. Los mecanismos descritos son más antiguos: pasar el indicador de solo lectura a la conexión JDBC existe desde Spring Framework 4.1, y pasarlo a la sesión de Hibernate desde Spring Framework 5.1. Si estás en Spring Boot 3, puedes tomar todo lo siguiente sin cambios.

La base de datos es PostgreSQL. Eso importa en un punto, porque PostgreSQL se comporta de forma distinta a H2 en las transacciones de solo lectura, y porque justo esa diferencia hace que un fallo no aparezca en las pruebas y sí en producción.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <scope>runtime</scope>
</dependency>

Por qué un repositorio tiene una transacción

Antes de empezar la búsqueda conviene mirar qué trae ya Spring Data sin ninguna ayuda. La implementación estándar detrás de cada repositorio JPA es SimpleJpaRepository, y esa clase está anotada como transaccional. A nivel de clase lleva @Transactional(readOnly = true), y los métodos de escritura como save o delete lo sobrescriben con un @Transactional simple.

De ahí se derivan dos cosas que conviene conocer.

Un save funciona incluso cuando nadie en el servicio ha abierto una transacción. Spring Data abre una por su cuenta, ejecuta la sentencia y confirma. Eso es cómodo y es la razón por la que muchos proyectos se apañan largo tiempo sin transacciones en la capa de servicio sin que nada llame la atención.

Lo segundo pesa más. La anotación cubre solo los métodos que SimpleJpaRepository implementa por su cuenta. De los métodos de consulta derivados como findByRunningTrueAndEndsOnBefore y de los @Query propios se encarga la maquinaria de consultas de Spring Data. Por eso no heredan nada.

Justo por eso se anota la interfaz. @Transactional(readOnly = true) en la interfaz mete tus propios métodos de consulta en el mismo régimen en el que ya están los métodos heredados. El beneficio es real, la anotación no es adorno.

@Transactional(readOnly = true)
public interface PromoPriceRepository extends JpaRepository<PromoPrice, Long> {

    List<PromoPrice> findByRunningTrueAndEndsOnBefore( LocalDate cutoff );
}

Que Spring desaconseje en general poner @Transactional en interfaces no se aplica aquí. En los repositorios de Spring Data este es el camino previsto, porque la interfaz es el único sitio en el que puedes anotar algo.

Qué activa realmente readOnly

El nombre sugiere que readOnly = true es una garantía, más o menos en el espíritu de final. No lo es. Es un indicador que se transmite a tres sitios y que en cada uno hace algo distinto.

Primero le toca a la sesión de Hibernate. Se pone en FlushMode.MANUAL. Con eso desaparece el volcado automático antes de las consultas y en la confirmación. Ya no hay ningún punto en el que Hibernate compare las entidades cargadas con su estado original.

Después viene el estado cargado. Desde Spring Framework 5.1 el indicador se pasa además a la sesión de Hibernate como defaultReadOnly. Hibernate descarta entonces de inmediato el estado cargado de una entidad en lugar de conservarlo durante toda la vida del contexto de persistencia. En funcionamiento normal ese estado es la base de la detección de cambios y ocupa memoria como un segundo juego de valores por cada entidad cargada.

Ese es el verdadero beneficio, y crece con el conjunto de resultados. En una lista de cinco aciertos no es medible. En una exportación de volúmenes de seis cifras es la diferencia entre un heap tranquilo y un trabajo que muere por memoria.

Por último, la conexión JDBC. Spring llama a Connection.setReadOnly(true) siempre que el interruptor prepareConnection de HibernateJpaDialect esté activo. Lo está por defecto desde Spring Framework 4.1. Lo que el controlador haga con ello depende de la base de datos, y ese punto merece su propia sección más abajo.

El trabajo que readOnly ahorra queda así claramente nombrado: ninguna comparación, ningún volcado, ningún segundo juego de valores en memoria. Lo que se pierde por el camino está en la sección siguiente.

La resolución: por qué el UPDATE nunca llegó

Volvamos al trabajo. El bucle pone running a false, y lo hace sobre entidades que sí están en el contexto de persistencia y sí están gestionadas. El setter se ejecuta, el campo en memoria cambia de verdad. Solo que ya nadie mira.

El volcado automático está apagado, así que en la confirmación no se produce ninguna comparación contra el estado original. Y como el estado original ya ni siquiera se conserva, tampoco habría nada que comparar. Hibernate no genera ningún UPDATE porque Hibernate no sabe nada del cambio.

Que no haya error es la peor propiedad de todo esto. Un rollback aparecería en el registro. Una excepción despertaría a la monitorización. Aquí sencillamente no pasa nada, y el trabajo informa de éxito porque expired.size() realmente es 412.

Sin readOnly
  cargar            la copia queda en memoria
  setRunning(false) campo cambiado en memoria
  confirmar         se compara con la copia, de ahi sale un UPDATE

Con readOnly
  cargar            la copia se descarta enseguida
  setRunning(false) campo cambiado en memoria
  confirmar         sin comparacion, por tanto sin UPDATE

Un segundo camino lleva al mismo resultado y se confunde a menudo con este. Si el servicio no tiene transacción alguna, el repositorio abre una propia para la consulta y la cierra al salir del método. Las entidades devueltas quedan desvinculadas. Un setter sobre una entidad desvinculada es una operación puramente en memoria, y aquí tampoco sigue ningún UPDATE. Dos causas distintas, el mismo síntoma, y ambas desaparecen en cuanto el límite transaccional se fija de forma consciente.

En PostgreSQL falla más alto

Resulta tentador esperar que la base de datos detecte el caso de la sección anterior. No lo hace, y el motivo es el mismo: donde Hibernate no genera ningún UPDATE, tampoco llega nada a la base de datos que ella pueda rechazar. En PostgreSQL el trabajo nocturno se queda igual de callado que en cualquier otro sitio.

Lo que PostgreSQL sí detecta es el otro tipo de fallo, una sentencia de escritura que realmente se envía mientras la transacción se abrió como de solo lectura. Como se ha descrito, Spring pasa el indicador de solo lectura hasta la conexión JDBC, y el controlador de PostgreSQL lo aprovecha.

El parámetro de conexión responsable es readOnlyMode, con tres valores posibles:

Valor Comportamiento con setReadOnly(true)
ignore El indicador queda sin consecuencias
transaction Con autocommit desactivado, el controlador envía BEGIN READ ONLY
always Como transaction, pero con autocommit activo se pone la sesión en solo lectura

El valor por defecto es transaction. Bajo una transacción de Spring el autocommit está desactivado. La transacción empieza por tanto como transacción de solo lectura explícita, y una sentencia que quiera escribir dentro de ella falla en la base de datos.

org.postgresql.util.PSQLException: ERROR: cannot execute UPDATE in a read-only transaction

En la práctica hay tres caminos que llevan ahí: una sentencia @Modifying sin su propio @Transactional, una consulta nativa de escritura, y un flush() explícito. Los tres envían algo de verdad. Por desagradable que parezca la excepción al principio, es el desenlace más amable. Un error que la monitorización ve es claramente preferible a un no-cambio silencioso.

Hay sin embargo un efecto secundario con el que los equipos tropiezan a menudo: el bloqueo pesimista mediante @Lock también genera un SELECT ... FOR UPDATE, y PostgreSQL lo rechaza igualmente dentro de una transacción de solo lectura. Una consulta con bloqueo no puede ejecutarse bajo un readOnly = true heredado, aunque funcionalmente solo lea.

Importante para la planificación de pruebas: H2 se comporta distinto aquí y deja pasar escrituras en una transacción marcada como de solo lectura. Una prueba contra H2 no puede encontrar este fallo. Esa es una de las razones por las que la base de datos de pruebas debería ser la misma que la de producción.

@Repository en la interfaz no hace nada

Una parada breve en la tercera anotación, porque está casi en todas partes y no consigue nada.

@Repository tiene dos tareas. Hace que una clase sea localizable para el escaneo de componentes y la registra para la traducción de las excepciones de persistencia a la jerarquía DataAccessException propia de Spring, que realiza el PersistenceExceptionTranslationPostProcessor.

Ambas cosas ya están resueltas en un repositorio de Spring Data antes de que la anotación pudiera entrar en juego. La interfaz se encuentra mediante el escaneo de repositorios, no mediante el escaneo de componentes, y la traducción de excepciones va incorporada en el proxy generado. En este sitio la anotación es pura costumbre.

@Repository                                   // sin efecto
@Transactional(readOnly = true)               // efectiva
public interface PromoPriceRepository extends JpaRepository<PromoPrice, Long> {
}

Sigue haciendo falta en cuanto escribes tú mismo una clase de acceso a datos, por ejemplo un adaptador que trabaje directamente con el EntityManager o con JdbcClient. Allí sí activa la traducción de excepciones.

@Repository
public class PromoPriceArchiveAdapter {

    private final JdbcClient jdbcClient;

    public PromoPriceArchiveAdapter( JdbcClient jdbcClient ) {
        this.jdbcClient = jdbcClient;
    }

    public int archiveUntil( LocalDate cutoff ) {
        return jdbcClient.sql( "insert into promo_price_archive select * from promo_price where ends_on < :cutoff" )
                .param( "cutoff", cutoff )
                .update();
    }
}

No hace falta que borres la anotación sobrante, no cuesta nada. Simplemente no deberías confiar en ella, porque no es la razón por la que tu repositorio funciona.

El segundo intento: @Modifying

La reparación evidente es dejar de ejecutar el trabajo sobre entidades cargadas. Cargar cuatrocientas filas una a una solo para cambiar un indicador en cada una es derrochador de todos modos. Basta con una única sentencia.

Para eso está @Modifying. Sin esta anotación, Spring Data intenta recoger el resultado de una @Query como lista de aciertos. Con ella la sentencia se ejecuta mediante executeUpdate() y devuelve el número de filas afectadas.

@Transactional(readOnly = true)
public interface PromoPriceRepository extends JpaRepository<PromoPrice, Long> {

    List<PromoPrice> findByRunningTrueAndEndsOnBefore( LocalDate cutoff );

    @Modifying
    @Transactional
    @Query("update PromoPrice p set p.running = false where p.running = true and p.endsOn < :cutoff")
    int endExpired( @Param("cutoff") LocalDate cutoff );
}

El segundo @Transactional en el método no es un descuido ni contabilidad por partida doble. La interfaz está en readOnly = true, y una anotación en el método gana frente a la de la clase. Sin ella, la sentencia de escritura correría bajo el indicador de solo lectura heredado, con las consecuencias de la sección anterior.

La sentencia también merece una mirada funcional. La condición p.running = true no sobra, aunque funcionalmente no cambie nada. Hace que una segunda ejecución no toque filas ya finalizadas y que el valor devuelto informe del número realmente modificado en lugar del total de promociones caducadas.

Con eso el trabajo queda bastante más corto:

@Scheduled(cron = "0 15 3 * * *")
public void endExpiredPromotions() {
    int affected = promoPrices.endExpired( LocalDate.now() );
    log.info( "{} promociones caducadas finalizadas", affected );
}

Esta vez el mensaje coincide con la base de datos. El trabajo hace lo que debe. El siguiente escollo está en otro sitio.

El contexto de persistencia obsoleto

El trabajo se va a ampliar. Antes de la ejecución se comprueba una promoción concreta porque llamó la atención en un caso de soporte, y después de la ejecución el registro debe decir qué le ha pasado a esa promoción.

@Transactional
public void maintenanceWithProbe( long probeId ) {
    PromoPrice probe = promoPrices.findById( probeId ).orElseThrow();

    int affected = promoPrices.endExpired( LocalDate.now() );

    log.info( "{} promociones finalizadas, sonda sigue vigente: {}", affected, probe.isRunning() );
}

La salida se contradice a sí misma:

2026-08-18 03:15:02  INFO  PriceMaintenance : 412 promociones finalizadas, sonda sigue vigente: true

La promoción está caducada, forma parte de las 412 filas modificadas y en la base de datos pone running = false. En memoria sigue poniendo true.

La razón es la naturaleza de una sentencia así. Una actualización masiva va directa a la base de datos y de largo por el contexto de persistencia. Hibernate no tiene forma de saber cuáles de las entidades gestionadas quedaron afectadas, porque para eso tendría que reproducir la condición WHERE en memoria. Así que las deja intactas. Cualquier acceso posterior a probe devuelve obedientemente el estado anterior a la actualización, y además sin consulta nueva, porque la entidad está en el contexto de persistencia.

La anotación tiene un interruptor para cada dirección, y ambos están por defecto en false.

@Modifying(clearAutomatically = true, flushAutomatically = true)
@Transactional
@Query("update PromoPrice p set p.running = false where p.running = true and p.endsOn < :cutoff")
int endExpired( @Param("cutoff") LocalDate cutoff );

flushAutomatically = true vuelca antes de la sentencia. Eso es necesario cuando antes, en el mismo flujo, se modificó una entidad que la condición WHERE podría alcanzar. Sin el volcado la base de datos aún no conoce ese cambio y trabaja por tanto sobre un estado que la aplicación ya ha dejado atrás.

clearAutomatically = true limpia el contexto de persistencia después de la sentencia. El siguiente acceso a probe lanza entonces una consulta nueva y devuelve el valor correcto.

El precio se pasa por alto con facilidad. Limpiar desvincula todas las entidades gestionadas, no solo las afectadas. Todo lo que se cargó y modificó antes en el mismo flujo y aún no se volcó queda perdido. Por eso los dos interruptores van juntos, y por eso la mitad de una operación de negocio larga es mal sitio para una actualización masiva. Una sentencia así funciona con más limpieza en su propia transacción corta, en la que no pasa nada más.

Qué más se salta una actualización masiva

El contexto de persistencia obsoleto es el punto más llamativo, pero no el único. Una sentencia que pasa de largo por la gestión de estado de Hibernate pasa también por delante de todo lo que cuelga de ella. Esa es la consecuencia lógica y justamente el precio de la velocidad.

Las devoluciones de llamada del ciclo de vida no se ejecutan. Ni @PreUpdate ni @PostUpdate, y en una sentencia de borrado tampoco @PreRemove. Quien mantenga marcas de modificación o rastros de auditoría mediante esas devoluciones de llamada las pierde aquí en silencio. Lo mismo vale para los escuchadores de eventos de Hibernate.

Las cascadas tampoco se aplican. Un delete from PromoPrice p where ... borra precios promocionales pero ninguna fila dependiente, aunque la relación esté declarada con cascade = REMOVE. Si en la base de datos hay una clave ajena, la sentencia falla. Si no la hay, quedan huérfanos, y eso suele aparecer meses después.

La columna de versión también se queda como está. Un campo con @Version no se incrementa, salvo que lo incrementes tú en la propia sentencia. Con eso una operación en paralelo sigue considerando actual su copia y el bloqueo optimista no salta, aunque la fila haya cambiado.

@Modifying(clearAutomatically = true, flushAutomatically = true)
@Transactional
@Query("update PromoPrice p set p.running = false, p.version = p.version + 1 "
     + "where p.running = true and p.endsOn < :cutoff")
int endExpired( @Param("cutoff") LocalDate cutoff );

Y la caché de segundo nivel no se entera de nada. Si la entidad tiene activada una caché de segundo nivel, sigue sirviendo los valores antiguos tras la actualización masiva, y además más allá de la transacción. Eso afecta también a otros usuarios.

Nada de esto habla en contra de las actualizaciones masivas. Habla en contra de intercalarlas de pasada. Una sentencia que modifica cuatrocientas filas de golpe merece una decisión consciente y un comentario en el código que explique por qué aquí no se toma el camino a través de las entidades.

El punto de inflexión: tres llamadas, tres transacciones

El trabajo sigue creciendo, como hacen los trabajos. Tras finalizar las promociones hay que marcar para reindexar las entradas de catálogo afectadas y escribir una entrada de auditoría. Tres llamadas al repositorio, bien separadas, cada una probada por su cuenta.

@Service
@Transactional(readOnly = true)
public class PriceMaintenance {

    @Scheduled(cron = "0 15 3 * * *")
    public void nightlyPriceMaintenance() {
        LocalDate today = LocalDate.now();

        int affected = promoPrices.endExpired( today );
        catalogEntries.flagForReindex( today );
        auditEntries.save( new AuditEntry( "price-maintenance", affected, today ) );
    }
}

El método en sí no tiene transacción de escritura. Hereda readOnly = true de la clase, y las tres llamadas traen cada una su propia transacción, porque están anotadas en el método o la heredan de SimpleJpaRepository.

Con eso cada llamada es atómica por su cuenta y el método en conjunto no lo es en absoluto.

Llamada 1   promoPrices.endExpired          BEGIN ... COMMIT
Llamada 2   catalogEntries.flagForReindex   BEGIN ... ERROR, ROLLBACK
Llamada 3   auditEntries.save               nunca alcanzada

Resultado   promociones finalizadas, el indice sigue anunciandolas, sin entrada de auditoria

Si falla la segunda llamada, las promociones ya están finalizadas y confirmadas. El índice de búsqueda sigue anunciando un precio que la tienda ya no ofrece, y falta la auditoría. Los datos quedan en un estado que funcionalmente no puede existir, y ninguna llamada individual hizo nada mal.

Este es el punto en el que aparece la pregunta de verdad. Todas las anotaciones del repositorio describen cómo se ejecuta una consulta individual. Ninguna describe qué es una unidad de trabajo. Tampoco puede, porque el repositorio no sabe nada del contexto desde el que se le llama.

La solución completa

El límite va donde está la lógica de negocio. El repositorio conserva sus anotaciones, siguen siendo correctas, simplemente no bastan.

@Transactional(readOnly = true)
public interface PromoPriceRepository extends JpaRepository<PromoPrice, Long> {

    List<PromoPrice> findByRunningTrueAndEndsOnBefore( LocalDate cutoff );

    @Modifying(clearAutomatically = true, flushAutomatically = true)
    @Transactional
    @Query("update PromoPrice p set p.running = false, p.version = p.version + 1 "
         + "where p.running = true and p.endsOn < :cutoff")
    int endExpired( @Param("cutoff") LocalDate cutoff );
}

El servicio pone el paréntesis. readOnly = true sigue siendo el valor por defecto de la clase porque la mayoría de los métodos leen, y el método de escritura lo levanta de forma explícita.

@Service
@Transactional(readOnly = true)
public class PriceMaintenance {

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

    private final PromoPriceRepository promoPrices;
    private final CatalogRepository catalogEntries;
    private final AuditRepository auditEntries;

    public PriceMaintenance( PromoPriceRepository promoPrices,
                             CatalogRepository catalogEntries,
                             AuditRepository auditEntries ) {
        this.promoPrices = promoPrices;
        this.catalogEntries = catalogEntries;
        this.auditEntries = auditEntries;
    }

    @Scheduled(cron = "0 15 3 * * *")
    @Transactional
    public void nightlyPriceMaintenance() {
        LocalDate today = LocalDate.now();

        int affected = promoPrices.endExpired( today );
        catalogEntries.flagForReindex( today );
        auditEntries.save( new AuditEntry( "price-maintenance", affected, today ) );

        log.info( "{} promociones caducadas finalizadas", affected );
    }
}

Ahora el método del servicio abre la transacción y las tres llamadas al repositorio corren dentro de ella, porque la propagación por defecto REQUIRED se une a una transacción existente en lugar de abrir una nueva. Si falla la segunda llamada, todo se revierte. Los datos conocen solo dos estados: antes del mantenimiento o después.

Queda un detalle que conviene tener presente, porque es la pregunta de seguimiento más habitual. La propagación REQUIRED se une a una transacción existente junto con su indicador de solo lectura. Un método con un @Transactional simple llamado desde dentro de una transacción de solo lectura no levanta el indicador. Se engancha a la transacción existente y sigue sin escribir. Por eso aquí @Transactional está en el método más externo y no en algún punto más interior.

Las anotaciones de un vistazo

Anotación Dónde va Qué hace Qué no puede hacer
@Repository Solo en clases de acceso a datos escritas a mano Escaneo de componentes, traducción de excepciones de persistencia Ningún efecto en una interfaz de Spring Data
@Transactional(readOnly = true) En la interfaz del repositorio como valor por defecto Sin volcado automático, sin estado cargado, indicador de solo lectura en la conexión No impide las escrituras, solo hace que desaparezcan en silencio
@Transactional en el método En cada método de repositorio que escribe Levanta el indicador de solo lectura de la interfaz No lo levanta si ya corre una transacción de solo lectura
@Modifying En cada @Query que escribe Ejecuta la sentencia mediante executeUpdate() No refresca el contexto de persistencia por sí sola
@Modifying(clearAutomatically = true) Cuando se sigue trabajando tras la sentencia Limpia después el contexto de persistencia Desvincula de paso todas las entidades gestionadas, no solo las afectadas
@Modifying(flushAutomatically = true) Cuando antes se modificaron entidades Vuelca antes de la sentencia No sustituye un orden consciente de las operaciones

¿readOnly va en el repositorio o en el servicio?

En ambos sitios, y por razones distintas.

En el repositorio es una red de seguridad. Garantiza que una consulta invocada por accidente sin transacción envolvente siga ejecutándose como lectura y conserve su frugalidad. Además documenta en la interfaz qué métodos leen y cuáles escriben, y esa información es más útil justo ahí.

En el servicio es la afirmación de negocio. Un método de servicio que arma un informe y por el camino consulta siete repositorios debe ejecutarse como una transacción de solo lectura, no como siete. Ese es el mayor palanca, porque ahí se decide cuántas conexiones se sacan del pool y cuántas consultas ven el mismo estado de los datos.

El orden en la práctica: primero fijar el límite en el servicio, después anotar el repositorio. Quien lo haga al revés se queda con un repositorio frugal en una aplicación sin límites transaccionales, y esa es justo la combinación de la escena del principio.

Cuándo readOnly no aporta nada

La parte honesta. readOnly = true no es un interruptor que se ponga en todas partes y que en todas partes rinda.

Con conjuntos de resultados pequeños la ganancia no es medible. Cargar una entidad por su clave y leer un campo te ahorra una comparación y un segundo juego de valores. Eso es ruido. El beneficio aparece con listas, informes y exportaciones, es decir, allí donde muchas entidades están a la vez en el contexto de persistencia.

Las proyecciones y las consultas DTO tampoco se benefician apenas, porque de entrada no surgen entidades gestionadas. No hay ningún estado cargado que ahorrar. Si de verdad solo quieres leer y el volumen es grande, una proyección suele ganarle a readOnly sobre entidades completas.

Con bloqueos pesimistas es incluso dañino, como se ha descrito arriba. Un método con @Lock no puede ejecutarse bajo un indicador de solo lectura heredado.

Lo que más pesa es que readOnly no sustituye a ninguna garantía. Quien quiera asegurarse de que una ruta de código no escribe necesita usuarios de base de datos separados con permisos distintos o una fuente de datos propia apuntando a una réplica de lectura. Una anotación no da eso. Es una medida de ahorro con un efecto secundario agradable, no un control de acceso.

Preguntas frecuentes

¿Necesito @Repository en mi interfaz de Spring Data?
No. La interfaz se encuentra mediante el escaneo de repositorios, y de la traducción de las excepciones de persistencia se encarga el proxy generado. La anotación solo hace falta en clases de acceso a datos escritas a mano.

¿Por qué necesito @Transactional además de @Modifying?
@Modifying solo dice cómo se ejecuta la sentencia, no abre ninguna transacción. Si la interfaz lleva readOnly = true, el método tiene que levantarlo de forma explícita, si no la sentencia de escritura corre bajo el indicador de solo lectura.

¿Por qué no veo mi cambio tras una actualización masiva?
Porque la sentencia pasa de largo por el contexto de persistencia y las entidades que están allí quedan sin cambios. @Modifying(clearAutomatically = true) limpia el contexto después, aunque junto con todas las demás entidades gestionadas.

¿readOnly = true impide escribir?
A nivel de aplicación no. Los cambios en las entidades desaparecen en silencio porque no se produce ninguna comparación y por tanto no se genera ninguna sentencia. En cuanto se envía realmente una sentencia de escritura, por ejemplo desde @Modifying sin su propio @Transactional, falla en la base de datos en PostgreSQL, porque el controlador abre la transacción como de solo lectura.

¿Por qué mi prueba pasa y en producción salta un error?
Lo más probable es que la prueba corra contra H2 y producción contra PostgreSQL. H2 deja pasar escrituras en una transacción marcada como de solo lectura, PostgreSQL no. Prueba contra la misma base de datos que operas.

¿Puedo levantar el indicador de solo lectura en un método interno?
Con la propagación por defecto REQUIRED no. El método interno se une a la transacción en curso junto con su indicador. O fijas el límite más afuera, o abres a conciencia una transacción propia con REQUIRES_NEW, lo que cuesta una segunda conexión del pool.

¿Cuántas transacciones corren si mi servicio no fija ningún límite?
Tantas como llamadas al repositorio hagas. Cada una trae la suya. Para una consulta individual está bien, para una unidad de trabajo de varios pasos no.

Conclusión

Las anotaciones de la interfaz del repositorio responden a una única pregunta: cómo se ejecuta una consulta individual. @Transactional(readOnly = true) hace las lecturas frugales y las escrituras invisibles. @Modifying convierte una consulta en una sentencia y el contexto de persistencia en una fuente de fallos cuando después se sigue trabajando. @Repository no hace nada en este sitio.

Lo que ninguna de ellas responde es dónde empieza y dónde acaba una unidad de trabajo. Ese límite lo fija el servicio, y cuando falta, la anotación más cuidadosa del repositorio no sirve de nada.

El siguiente paso es pequeño y compensa de inmediato: repasa las clases de servicio que hacen más de una llamada al repositorio en un método y comprueba si allí se abre una transacción. Donde no la haya tienes exactamente la construcción del ejemplo de arriba, solo que sin el trabajo nocturno que la hace visible.

Fuentes

Todos los ejemplos de código son propios y se ejecutaron en Spring Boot 4 con PostgreSQL.

$ lang DE EN ES