Spring Boot
MongoDB Integration
Connect a Spring Boot 3 application to MongoDB with Spring Data MongoDB: connection setup, document mapping, repositories, MongoTemplate queries, aggregations, indexes and Testcontainers tests. It also covers the mistakes that cost people hours: mixing uri with host settings, indexes that are silently never created, and BigDecimal prices stored as strings.
Dependency
One starter pulls in the MongoDB driver, Spring Data MongoDB and auto-configuration for MongoClient, MongoTemplate and repositories. Nothing else is required to get started; the connection details are the only mandatory setting.
XML
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency>Connection Configuration
Use either a single uri or the separate host, port, username and password properties, never both. Spring Boot refuses to start when they are mixed, with the error "Invalid mongo configuration, either uri or host/port/credentials/replicaSet must be specified". Most teams use the URI because it carries options such as retryWrites and the auth database in one place. Two details are easy to miss. First, if the password contains special characters (@, :, /), it must be URL-encoded inside the URI. Second, the URI must be on a single line; a YAML folded string that wraps the URI inserts a space and breaks it. Note also that auto-index-creation is switched on here, which the Indexes section explains.
yaml
# application.yml (local development)
spring:
data:
mongodb:
uri: mongodb://appuser:${MONGO_PASSWORD}@localhost:27017/mydb?authSource=admin
auto-index-creation: true
# application-prod.yml (MongoDB Atlas, SRV connection string, TLS is implied)
spring:
data:
mongodb:
uri: mongodb+srv://appuser:${MONGO_PASSWORD}@cluster0.abc123.mongodb.net/mydb?retryWrites=true&w=majorityDocument Mapping
A class annotated with @Document becomes a collection; @Id maps to the _id field, and a String id is stored as an ObjectId automatically. Child objects that are always read together with their parent, such as the address and variants below, are embedded as sub-documents. Embedded classes need no annotation. One mapping detail matters more than it looks: Spring Data stores BigDecimal as a String by default. Range queries, sorting and $avg on a String price compare text, so "9.99" sorts after "10.00". Declaring the field as DECIMAL128 stores a real decimal number and fixes all three. @CreatedDate and @LastModifiedDate only work when auditing is enabled with @EnableMongoAuditing on a configuration class, and @Version adds optimistic locking.
Java
@Document(collection = "products")
public class Product {
@Id
private String id;
private String name;
private String description;
@Field(targetType = FieldType.DECIMAL128) // store as a decimal, not a String
private BigDecimal price;
private String category;
private String sku;
private boolean active = true;
private List<String> tags = new ArrayList<>();
private Address warehouseAddress; // embedded sub-document
private List<ProductVariant> variants = new ArrayList<>(); // list of sub-documents
private Map<String, String> attributes = new HashMap<>(); // free-form key/value
@CreatedDate
private LocalDateTime createdAt;
@LastModifiedDate
private LocalDateTime updatedAt;
@Version
private Long version;
// getters and setters omitted
}
public record Address(String street, String city, String country) { }
public record ProductVariant(String sku, String colour, String size,
BigDecimal priceAdjustment) { }
// Enable auditing once, on any @Configuration class or the main class:
@SpringBootApplication
@EnableMongoAuditing
public class MyApplication { }Indexes
Since Spring Data MongoDB 3.0, indexes declared with annotations are not created unless you set spring.data.mongodb.auto-index-creation to true. This is the most common reason a unique constraint "does nothing": the annotation is there, but the index was never built, and duplicates are silently accepted. Auto-creation is convenient in development and tests. In production many teams prefer to create indexes with a migration tool (Mongock is a common choice) so index builds on large collections are planned rather than triggered at startup. A compound index follows the same rule as in SQL databases: put the field you filter by equality first and the range or sort field after it. A collection can have only one text index, so @TextIndexed fields are grouped into it.
Java
@Document(collection = "products")
@CompoundIndex(name = "idx_category_price", def = "{'category': 1, 'price': -1}")
public class Product {
@Indexed(unique = true)
private String sku;
@Indexed
private String category;
@TextIndexed(weight = 2) // name matches rank higher than description matches
private String name;
@TextIndexed
private String description;
// other fields as in the mapping section
}
// TTL index: MongoDB deletes the document once expiresAt + expireAfterSeconds has passed.
// With 0 it is deleted at the moment stored in expiresAt. The cleanup job runs about once a minute.
@Document(collection = "password_reset_tokens")
public class PasswordResetToken {
@Id
private String id;
private String userId;
@Indexed(expireAfterSeconds = 0)
private Instant expiresAt;
}MongoRepository
Derived query methods work like they do with JPA: Spring Data parses the method name and builds the MongoDB query. Nested fields are reached by property name, so findByVariantsSku searches variants.sku inside the embedded list. For queries the method name cannot express, write the filter as MongoDB JSON in @Query; ?0 and ?1 are positional parameters. The @Update annotation (Spring Data MongoDB 3.4+) performs an update-many in the database without loading the documents first, and returns the number of modified documents. Be aware that findByNameContainingIgnoreCase becomes a case-insensitive regular expression, which cannot use a normal index and scans the collection. For real text search on larger collections, use the text index with a TextCriteria query instead.
Java
@Repository
public interface ProductRepository extends MongoRepository<Product, String> {
Optional<Product> findBySku(String sku);
boolean existsBySku(String sku);
long countByCategory(String category);
List<Product> findByCategoryAndPriceLessThan(String category, BigDecimal max);
List<Product> findByNameContainingIgnoreCase(String fragment); // regex, no index use
List<Product> findByVariantsSku(String sku); // nested field
List<Product> findByActiveTrueOrderByPriceAsc();
Page<Product> findByCategory(String category, Pageable pageable);
@Query("{ 'price': { $gte: ?0, $lte: ?1 }, 'active': true }")
List<Product> findActiveInPriceRange(BigDecimal min, BigDecimal max);
// Projection: only load the listed fields
@Query(value = "{ 'category': ?0 }", fields = "{ 'name': 1, 'price': 1, 'sku': 1 }")
List<Product> findSummaryByCategory(String category);
// Update-many in the database; returns how many documents changed
@Query("{ 'category': ?0 }")
@Update("{ '$set': { 'active': ?1 } }")
long updateActiveByCategory(String category, boolean active);
}Dynamic Queries with MongoTemplate
Repositories are fine for fixed queries. When the filter depends on which search fields the user filled in, build it with Criteria and run it through MongoTemplate. Collect each condition in a list and combine them with andOperator, which avoids the problem of using the same field twice (for example gte and lte on price) in one Criteria object. Escape user-supplied text with Pattern.quote before using it in a regex; otherwise a search for "c++" throws an error, and a crafted pattern can make the query very slow. For pagination, fetch the page first and let PageableExecutionUtils run the count query only when it is actually needed, for example not on a short last page. The count query must drop the skip and limit of the page query, which is what limit(-1).skip(-1) does.
Java
@Service
public class ProductSearchService {
private final MongoTemplate mongoTemplate;
public ProductSearchService(MongoTemplate mongoTemplate) {
this.mongoTemplate = mongoTemplate;
}
public Page<Product> search(ProductSearchRequest req, Pageable pageable) {
List<Criteria> filters = new ArrayList<>();
if (req.name() != null && !req.name().isBlank())
filters.add(Criteria.where("name").regex(Pattern.quote(req.name()), "i"));
if (req.category() != null)
filters.add(Criteria.where("category").is(req.category()));
if (req.minPrice() != null)
filters.add(Criteria.where("price").gte(req.minPrice()));
if (req.maxPrice() != null)
filters.add(Criteria.where("price").lte(req.maxPrice()));
if (req.tags() != null && !req.tags().isEmpty())
filters.add(Criteria.where("tags").in(req.tags()));
Criteria criteria = filters.isEmpty()
? new Criteria()
: new Criteria().andOperator(filters.toArray(new Criteria[0]));
Query query = new Query(criteria).with(pageable); // pageable also carries the sort
List<Product> content = mongoTemplate.find(query, Product.class);
return PageableExecutionUtils.getPage(content, pageable,
() -> mongoTemplate.count(Query.of(query).limit(-1).skip(-1), Product.class));
}
}
public record ProductSearchRequest(String name, String category,
BigDecimal minPrice, BigDecimal maxPrice,
List<String> tags) { }Aggregations, Upserts and Bulk Writes
An aggregation pipeline is MongoDB's equivalent of GROUP BY with extra stages: filter first with match so later stages see fewer documents, then group, sort and limit. After a group stage the grouping key lives in _id, so the final project renames it back to category with previousOperation(). An upsert updates the document matching the query or inserts it if none exists; setOnInsert values are written only on insert. Remember that template updates bypass auditing and @Version, so set updatedAt yourself, and back the upsert key (sku here) with a unique index so two concurrent upserts cannot create duplicates. For many documents, bulk operations send them in one round trip. UNORDERED lets MongoDB continue past a failed item and run operations in parallel. Guard against an empty list, because executing an empty bulk operation throws an exception.
Java
public List<CategorySummary> getCategorySummaries() {
Aggregation agg = Aggregation.newAggregation(
Aggregation.match(Criteria.where("active").is(true)),
Aggregation.group("category")
.count().as("productCount")
.avg("price").as("averagePrice")
.min("price").as("minPrice")
.max("price").as("maxPrice"),
Aggregation.sort(Sort.by(Sort.Direction.DESC, "productCount")),
Aggregation.limit(10),
Aggregation.project("productCount", "averagePrice", "minPrice", "maxPrice")
.and("category").previousOperation()
.andExclude("_id")
);
return mongoTemplate.aggregate(agg, Product.class, CategorySummary.class)
.getMappedResults();
}
public record CategorySummary(String category, long productCount,
BigDecimal averagePrice, BigDecimal minPrice,
BigDecimal maxPrice) { }
public void upsertProduct(Product product) {
Query query = Query.query(Criteria.where("sku").is(product.getSku()));
Update update = new Update()
.set("name", product.getName())
.set("price", product.getPrice())
.set("updatedAt", LocalDateTime.now())
.setOnInsert("createdAt", LocalDateTime.now());
mongoTemplate.upsert(query, update, Product.class);
}
public void bulkUpsert(List<Product> products) {
if (products.isEmpty()) return; // empty bulk operations throw
BulkOperations ops = mongoTemplate.bulkOps(
BulkOperations.BulkMode.UNORDERED, Product.class);
for (Product p : products) {
ops.upsert(
Query.query(Criteria.where("sku").is(p.getSku())),
new Update().set("name", p.getName()).set("price", p.getPrice()));
}
ops.execute();
}Connection Pool Tuning
Prefer a MongoClientSettingsBuilderCustomizer over writing your own MongoClient configuration. It adjusts the client Spring Boot already builds from your properties, so the URI in application.yml keeps working per environment. Extending AbstractMongoClientConfiguration, which many older tutorials show, replaces the auto-configuration and typically ends with a connection string hardcoded in Java that silently ignores your YAML. The same limits can also be put in the URI itself (for example maxPoolSize=20), which is the simplest option if you do not need code.
Java
@Configuration
public class MongoPoolConfig {
@Bean
MongoClientSettingsBuilderCustomizer mongoPoolCustomizer() {
return builder -> builder
.applyToConnectionPoolSettings(pool -> pool
.maxSize(20)
.minSize(5)
.maxWaitTime(3, TimeUnit.SECONDS)
.maxConnectionIdleTime(10, TimeUnit.MINUTES))
.applyToSocketSettings(socket -> socket
.connectTimeout(5, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS));
}
}Local Development with Docker
The root user created by the image lives in the admin database, which is why the URI in the configuration section ends with authSource=admin. Without it, connecting fails with an authentication error even though the password is correct. Use your own username in real projects instead of root. This compose file starts a standalone server, which is enough for everything in this page except multi-document transactions; those require a replica set, even a single-node one.
yaml
# docker-compose.yml
services:
mongodb:
image: mongo:7
container_name: myapp-mongo
environment:
MONGO_INITDB_ROOT_USERNAME: appuser
MONGO_INITDB_ROOT_PASSWORD: apppassword
ports:
- "27017:27017"
volumes:
- mongo_data:/data/db
volumes:
mongo_data:Testing with Testcontainers
@DataMongoTest loads only the MongoDB part of the application, which keeps the test fast. Combined with a Testcontainers MongoDB container and @ServiceConnection (Spring Boot 3.1+), the test runs against a real server with no manual property wiring. The example verifies something that an in-memory fake would not reliably catch: that the unique index on sku really rejects duplicates. It needs auto-index-creation switched on, as shown in the test annotation. Docker must be running when the tests execute.
Java
<!-- pom.xml -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>mongodb</artifactId>
<scope>test</scope>
</dependency>
// Test class
@DataMongoTest(properties = "spring.data.mongodb.auto-index-creation=true")
@Testcontainers
class ProductRepositoryTest {
@Container
@ServiceConnection
static MongoDBContainer mongo = new MongoDBContainer("mongo:7");
@Autowired
private ProductRepository productRepository;
@Test
void uniqueSkuIsEnforced() {
productRepository.save(newProduct("SKU-1"));
assertThatThrownBy(() -> productRepository.save(newProduct("SKU-1")))
.isInstanceOf(DuplicateKeyException.class);
}
private static Product newProduct(String sku) {
Product p = new Product();
p.setSku(sku);
p.setName("Widget");
p.setPrice(new BigDecimal("9.99"));
return p;
}
}Errors You Will Probably Hit
"Invalid mongo configuration, either uri or host/port/credentials/replicaSet must be specified" means uri and host or port are both set; keep one style. Duplicate documents despite @Indexed(unique = true) mean auto-index-creation is false, so the index does not exist; check with db.products.getIndexes() in mongosh. "Authentication failed" with a correct password is usually a missing authSource, because the user was created in the admin database. "Transaction numbers are only allowed on a replica set member or mongos" appears when you use @Transactional against a standalone server. An OptimisticLockingFailureException means two writers changed the same document and the second one lost; reload and retry.
Java
// Check which indexes really exist (mongosh)
db.products.getIndexes()
// Fix for "Authentication failed": name the database holding the user
// spring.data.mongodb.uri: mongodb://appuser:${MONGO_PASSWORD}@localhost:27017/mydb?authSource=adminWhen MongoDB Is, and Is Not, the Right Choice
MongoDB fits data that is read and written as a whole unit, varies in shape between records, or arrives in high volume, such as product catalogues, event logs and content. Embed child data that is always loaded with its parent and stays small. Use a reference (store the other document's id and look it up) when the child is shared, changes independently or can grow without limit, because a single document is capped at 16 MB and an ever-growing embedded array will eventually hit that limit and also slow every write. Be cautious with @DBRef: it makes extra queries per reference and offers no joins, so most teams store plain ids instead. If your data is highly relational, with many joins and strict multi-table integrity, a relational database such as PostgreSQL is usually the simpler choice.
Java
// Embed: line items are small and always read with their order
@Document("orders")
public class Order {
@Id private String id;
private List<OrderLine> lines = new ArrayList<>(); // bounded, owned by the order
}
// Reference: a customer is shared by many orders and changes on its own
@Document("orders")
public class OrderWithCustomerRef {
@Id private String id;
private String customerId; // look up when needed
}