Spring Data MongoDB: persistencia NoSQL con Spring Boot
Cuando los datos no encajan bien en tablas relacionales —estructuras variables, documentos anidados, arrays de longitud arbitraria o esquemas que evolucionan con frecuencia— MongoDB ofrece una alternativa natural. Spring Data MongoDB lleva la misma filosofía de Spring Data JPA al mundo de los documentos: repositorios sin implementación manual, queries derivadas del nombre del método y acceso de bajo nivel cuando lo necesitas.
Este artículo cubre desde la configuración inicial hasta las consultas avanzadas con MongoTemplate y aggregation pipelines.
Dependencias y configuración
Agrega el starter de MongoDB a tu proyecto Spring Boot:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency>La configuración mínima en application.yml:
spring:
data:
mongodb:
uri: mongodb://localhost:27017/mydbSi usas autenticación:
spring:
data:
mongodb:
uri: mongodb://user:password@localhost:27017/mydb?authSource=adminPara MongoDB Atlas (la nube administrada de MongoDB):
spring:
data:
mongodb:
uri: mongodb+srv://user:password@cluster0.example.mongodb.net/mydbSpring Boot autoconfigura MongoClient y MongoTemplate a partir de esta URI. No necesitas ninguna clase de configuración adicional para empezar.
Modelado de documentos con @Document
Un documento MongoDB es el equivalente a una fila en SQL. Lo mapeas con @Document:
@Document(collection = "products")
@Getter
@Setter
@NoArgsConstructor
public class Product {
@Id
private String id; // MongoDB usa ObjectId como _id; String lo mapea automáticamente
@Field("name")
private String name;
private BigDecimal price;
private String category;
@DBRef
private Supplier supplier; // referencia a otro documento (similar a FK, pero no JOIN)
private List<String> tags = new ArrayList<>();
private Address address; // documento embebido (sin @Document)
@CreatedDate
private Instant createdAt;
@LastModifiedDate
private Instant updatedAt;
}Y la clase embebida Address, que se guarda dentro del mismo documento sin colección propia:
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
public class Address {
private String street;
private String city;
private String country;
}Puntos clave del modelo:
@Id: mapea el campo al_idde MongoDB. Si el tipo esString, Spring Data genera unObjectIdy lo convierte automáticamente.@Field: personaliza el nombre del campo en el documento. Útil para mantener convenciones de nomenclatura distintas entre Java y MongoDB.@DBRef: almacena una referencia al_iddel documento referenciado. MongoDB no realiza joins; la carga es lazy por defecto y requiere una segunda consulta.- Documentos embebidos: incrustar datos relacionados (como
Address) dentro del mismo documento es la estrategia preferida en MongoDB. Elimina joins y mejora el rendimiento de lectura. @CreatedDate/@LastModifiedDate: requieren activar la auditoría con@EnableMongoAuditingen una clase de configuración.
Activar auditoría
@Configuration
@EnableMongoAuditing
public class MongoConfig {
}Repositorios
La interfaz mínima para un repositorio de documentos:
public interface ProductRepository extends MongoRepository<Product, String> {
}MongoRepository<Product, String> te da las operaciones CRUD estándar: save, findById, findAll, deleteById, count, existsById, entre otras.
Query methods (queries derivadas)
Spring Data MongoDB infiere el query a partir del nombre del método, igual que con JPA:
public interface ProductRepository extends MongoRepository<Product, String> {
// db.products.find({ name: "..." })
List<Product> findByName(String name);
// db.products.find({ price: { $lt: maxPrice } })
List<Product> findByPriceLessThan(BigDecimal maxPrice);
// db.products.find({ category: "...", price: { $lte: maxPrice } })
List<Product> findByCategoryAndPriceLessThanEqual(String category, BigDecimal maxPrice);
// db.products.find({ tags: "electronics" })
List<Product> findByTagsContaining(String tag);
// db.products.find({ "address.city": "..." })
List<Product> findByAddressCity(String city);
// db.products.find({ name: /keyword/i })
List<Product> findByNameContainingIgnoreCase(String keyword);
boolean existsByName(String name);
long countByCategory(String category);
}La notación de punto en los nombres de método (AddressCity) navega los campos de documentos embebidos. MongoDB traduce esto al path address.city en el filtro.
Queries personalizadas con @Query
Para queries más complejas, usa la anotación @Query con la sintaxis JSON de MongoDB:
public interface ProductRepository extends MongoRepository<Product, String> {
// Búsqueda de texto con regex (insensible a mayúsculas)
@Query("{ 'name': { $regex: ?0, $options: 'i' } }")
List<Product> searchByName(String pattern);
// Filtro por rango de precio y categoría
@Query("{ 'price': { $gte: ?0, $lte: ?1 }, 'category': ?2 }")
List<Product> findByPriceRangeAndCategory(BigDecimal min, BigDecimal max, String category);
// Proyección: devuelve solo name y price (sin el resto de campos)
@Query(value = "{ 'category': ?0 }", fields = "{ 'name': 1, 'price': 1 }")
List<Product> findNameAndPriceByCategory(String category);
// Operador $in: busca documentos cuya categoría esté en la lista
@Query("{ 'category': { $in: ?0 } }")
List<Product> findByCategoryIn(List<String> categories);
// Documentos con el array 'tags' no vacío
@Query("{ 'tags': { $exists: true, $not: { $size: 0 } } }")
List<Product> findWithTags();
}En @Query, ?0, ?1, ?2 son parámetros posicionales. El valor de fields es la proyección de MongoDB: 1 incluye el campo, 0 lo excluye.
MongoTemplate: control de bajo nivel
MongoTemplate es el punto de acceso de bajo nivel, equivalente a JdbcTemplate en el mundo relacional. Úsalo cuando las queries derivadas y @Query no son suficientes.
@Service
@RequiredArgsConstructor
public class ProductService {
private final MongoTemplate mongoTemplate;
public List<Product> findExpensiveInCategory(String category, BigDecimal minPrice) {
Query query = new Query(
Criteria.where("category").is(category)
.and("price").gte(minPrice)
).with(Sort.by(Sort.Direction.DESC, "price"))
.limit(10);
return mongoTemplate.find(query, Product.class);
}
public long countByTag(String tag) {
Query query = new Query(Criteria.where("tags").in(tag));
return mongoTemplate.count(query, Product.class);
}
public UpdateResult updatePrice(String id, BigDecimal newPrice) {
Query query = new Query(Criteria.where("id").is(id));
Update update = new Update().set("price", newPrice);
return mongoTemplate.updateFirst(query, update, Product.class);
}
public void addTagToAllInCategory(String category, String tag) {
Query query = new Query(Criteria.where("category").is(category));
Update update = new Update().addToSet("tags", tag); // $addToSet evita duplicados
mongoTemplate.updateMulti(query, update, Product.class);
}
}La clase Criteria construye los filtros de forma fluida. Update construye las operaciones de modificación usando los operadores de MongoDB ($set, $unset, $inc, $addToSet, $push, $pull, etc.).
Paginación
La paginación funciona igual que en Spring Data JPA:
public interface ProductRepository extends MongoRepository<Product, String> {
Page<Product> findByCategory(String category, Pageable pageable);
@Query("{ 'name': { $regex: ?0, $options: 'i' } }")
Page<Product> search(String keyword, Pageable pageable);
}Desde el servicio:
public Page<Product> searchProducts(String keyword, int page, int size) {
Pageable pageable = PageRequest.of(page, size, Sort.by("price").ascending());
return productRepository.search(keyword, pageable);
}Índices
MongoDB realiza las consultas en un full collection scan si no existen índices. Para colecciones grandes, los índices son imprescindibles.
Índices con anotaciones
@Document(collection = "products")
public class Product {
@Id
private String id;
@Indexed(unique = true)
private String sku; // índice único
@Indexed
private String category; // índice simple
@TextIndexed
private String description; // índice de texto completo
private BigDecimal price;
}Activa la creación automática de índices en application.yml:
spring:
data:
mongodb:
auto-index-creation: trueÍndices compuestos con @CompoundIndex
@Document(collection = "products")
@CompoundIndex(name = "category_price_idx", def = "{ 'category': 1, 'price': -1 }")
public class Product {
// ...
}El número 1 indica orden ascendente y -1 descendente. Los índices compuestos aceleran consultas que filtran por ambos campos a la vez.
Crear índices programáticamente
En producción es preferible crear índices de forma explícita para tener control total sobre el proceso:
@Component
@RequiredArgsConstructor
public class MongoIndexInitializer implements ApplicationRunner {
private final MongoTemplate mongoTemplate;
@Override
public void run(ApplicationArguments args) {
IndexOperations indexOps = mongoTemplate.indexOps(Product.class);
// Índice compuesto
indexOps.ensureIndex(new Index()
.on("category", Sort.Direction.ASC)
.on("price", Sort.Direction.DESC)
.named("category_price_idx"));
// Índice único
indexOps.ensureIndex(new Index()
.on("sku", Sort.Direction.ASC)
.unique()
.named("sku_unique_idx"));
// Índice TTL: elimina documentos automáticamente después de 30 días
indexOps.ensureIndex(new Index()
.on("createdAt", Sort.Direction.ASC)
.expire(30, TimeUnit.DAYS)
.named("ttl_30days_idx"));
}
}Los índices TTL son una característica de MongoDB que expira y elimina documentos automáticamente. Son útiles para logs, sesiones, tokens temporales o cualquier dato con vida útil acotada.
Aggregation Framework
El aggregation pipeline de MongoDB permite transformar y agrupar documentos en varias etapas. MongoTemplate lo expone con una API fluida:
@Service
@RequiredArgsConstructor
public class ProductStatsService {
private final MongoTemplate mongoTemplate;
// Precio promedio por categoría
public List<CategoryStats> getAveragepriceByCategory() {
Aggregation aggregation = Aggregation.newAggregation(
Aggregation.group("category")
.avg("price").as("averagePrice")
.count().as("totalProducts"),
Aggregation.sort(Sort.Direction.DESC, "averagePrice"),
Aggregation.project("averagePrice", "totalProducts")
.and("_id").as("category")
);
return mongoTemplate.aggregate(
aggregation, "products", CategoryStats.class
).getMappedResults();
}
// Productos más caros por categoría (top 1 por grupo)
public List<Product> getMostExpensivePerCategory() {
Aggregation aggregation = Aggregation.newAggregation(
Aggregation.sort(Sort.Direction.DESC, "price"),
Aggregation.group("category")
.first("$ROOT").as("product"),
Aggregation.replaceRoot("product")
);
return mongoTemplate.aggregate(
aggregation, "products", Product.class
).getMappedResults();
}
}
public record CategoryStats(String category, Double averagePrice, Long totalProducts) {}Las etapas más usadas del pipeline son:
$match: filtra documentos (equivalente aWHEREen SQL).$group: agrupa y calcula acumuladores ($avg,$sum,$min,$max,$count).$sort: ordena los documentos.$project: selecciona o transforma campos (equivalente aSELECT).$limit/$skip: paginación.$lookup: join entre colecciones (usar con moderación).$unwind: descompone arrays en documentos individuales.
Buenas prácticas
Diseña el esquema orientado a las queries. En MongoDB el esquema se diseña pensando en cómo se consultan los datos, no en la normalización. Si siempre lees un producto con su categoría, incrustar la categoría en el documento de producto es más eficiente que usar @DBRef.
Evita @DBRef cuando puedas. Las referencias @DBRef requieren una segunda consulta para cargar el documento referenciado. En muchos casos, incrustar los datos relevantes o guardar solo el ID y resolver la referencia en la capa de servicio es más eficiente.
No abuses de auto-index-creation en producción. La creación automática de índices al arrancar puede causar problemas si las colecciones son grandes. Gestiona los índices como parte del proceso de despliegue.
Limita el tamaño de los arrays embebidos. MongoDB tiene un límite de 16 MB por documento. Arrays que crecen indefinidamente son un antipatrón; considera moverlos a una colección separada si pueden superar cientos de elementos.
Usa MongoRepository para CRUD simple y MongoTemplate para queries complejas. La combinación de ambos cubre la mayoría de los casos sin sacrificar legibilidad ni flexibilidad.
Activa el profiler de MongoDB en desarrollo para detectar queries lentas y consultas sin índice:
// En la consola de MongoDB
db.setProfilingLevel(1, { slowms: 100 }) // loguea queries > 100ms
db.system.profile.find().sort({ ts: -1 }).limit(10)Resumen
Spring Data MongoDB sigue la misma filosofía de Spring Data JPA: un repositorio declarativo te ahorra el código repetitivo y MongoTemplate cubre los casos que la abstracción no alcanza. Los conceptos centrales son:
@Document: mapea una clase Java a una colección MongoDB. Los documentos embebidos son la estrategia preferida para relacionar datos.MongoRepository: repositorio con CRUD y queries derivadas sin implementación manual.@Query: queries personalizadas con la sintaxis JSON de MongoDB.MongoTemplate: acceso de bajo nivel conCriteria,QueryyUpdatepara escenarios complejos.- Índices: imprescindibles para consultas eficientes. Los índices TTL añaden expiración automática de documentos.
- Aggregation pipeline: transforma y agrupa documentos en múltiples etapas para reportes y análisis.
La elección entre JPA/SQL y MongoDB no es ideológica. Si tus datos son naturalmente documentales, con esquemas variables o estructuras jerárquicas profundas, MongoDB simplifica el modelo. Si tus datos son relacionales y la integridad referencial es crítica, una base de datos SQL sigue siendo la opción más robusta.
