feat: redis, fileserver, httpclient 런타임 시점 구현 추가

This commit is contained in:
donghyeon-ka
2026-07-28 14:26:54 +09:00
parent 7363b2aa1e
commit b3add0162d
257 changed files with 30430 additions and 1357 deletions
@@ -4,47 +4,45 @@
- Module ID: `adapter-outbound-persistence-mongo`
- Gradle path: `:adapter:outbound:persistence-mongo`
- Focused test: `./gradlew :adapter:outbound:persistence-mongo:test --console=plain`
- Focused test (derived from Gradle path): `./gradlew :adapter:outbound:persistence-mongo:test --console=plain`
- Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0.
- Registry SSOT: `.harness/project/modules.yaml`.
- Registry SSOT: `src/config/architecture/modules.json`.
Package root: `dev.caskeleton.adapter.outbound.mongo`. Driven (outbound) adapter — **lightweight
Spring Data MongoDB scaffolding**. Design rationale lives in [README.md](README.md).
Package root: `dev.caskeleton.adapter.outbound.mongo`. Driven (outbound) adapter — opt-in Spring
Data MongoDB infrastructure. Design rationale lives in [README.md](README.md).
## Responsibility
- Demonstrate MongoDB-backed persistence: opt-in Mongo config + a self-contained example document /
repository / adapter showing the document↔domain mapping boundary. It does **not** reimplement
idempotency / outbox / lock on Mongo (those stay JPA-only).
- Provide opt-in Mongo client and template infrastructure without shipping a fake business domain.
- Real forks add their own document, repository, mapper, and application/domain port implementation.
- It does **not** reimplement idempotency / outbox / lock on Mongo (those stay JPA-only).
- Opt-in: `MongoPersistenceConfig` re-imports the Mongo auto-configuration (`@ImportAutoConfiguration`)
and enables repositories (`@EnableMongoRepositories`, scoped to this package via
`basePackageClasses`) only when `ca-skeleton.persistence-mongo.enabled=true` (default off). The
connection URI comes from Spring's standard `spring.data.mongodb.uri`.
only when `ca-skeleton.persistence-mongo.enabled=true` (default off). The connection URI and
database come from Spring's standard `spring.data.mongodb.*` settings.
- `MongoOptInAutoConfigurationImportFilter`, registered through `META-INF/spring.factories`, blocks
Boot 4's classpath-driven sync/reactive/data/repository/health/metrics Mongo auto-configuration
when the module enable flag is absent or false.
## Allowed
- Project deps: `:application-core`, `:shared-contract` — SSOT is the
`adapter-outbound-persistence-mongo` entry in `.harness/project/modules.yaml`; `src/build.gradle`
enforces it. No
`:domain-core`, no sibling adapters.
- No project dependency is required by the generic infrastructure. The allowed-edge SSOT remains
the `adapter-outbound-persistence-mongo` entry in `src/config/architecture/modules.json`.
- External: `org.springframework.boot:spring-boot-starter-data-mongodb` (version via the shared
Spring Boot BOM), `spring-boot-configuration-processor` (annotation processor). Test-only:
Testcontainers (`testcontainers`, `testcontainers-junit-jupiter`), BOM-managed.
Spring Boot BOM), `spring-boot-configuration-processor` (annotation processor).
## Forbidden
- Inbound adapters, sibling outbound adapters, `app-bootstrap`, `sample-portfolio` (ArchUnit
`OUTBOUND_ADAPTERS_*` family rules).
- Leaking the `ExampleMongoDocument` type outside the adapter — the adapter maps documents to the
module-local `ExampleRecord` at the edge.
- Inventing an `application-core` port for the example (scaffolding stays self-contained); adding
idempotency/outbox/lock on Mongo.
- Shipping placeholder `Example*` document, repository, record, or adapter types in production.
- Adding idempotency/outbox/lock on Mongo without a separately approved contract.
- Fully-qualified inline type references; more than one public top-level type per file.
## Tests
`ExampleMongoMapperTest` (pure mapping, no container), `ExampleMongoRepositoryIT` (Testcontainers
MongoDB save/find/derived-query, `disabledWithoutDocker`).
`MongoPersistenceConfigTest` proves default/false behavior through an actual
`@EnableAutoConfiguration` context, typed enablement binding, and enabled infrastructure with a
mock `MongoClient` plus a real `MongoTemplate` without a network connection.
```bash
cd src
@@ -1,68 +1,50 @@
# adapter:outbound:persistence-mongo — design-decision reference
# adapter:outbound:persistence-mongo
MongoDB persistence outbound (driven) adapter — **lightweight scaffolding**. Package root:
`dev.caskeleton.adapter.outbound.mongo`. Wires Spring Data MongoDB behind an opt-in
`@ConditionalOnProperty` selector and ships a demonstrative document / repository / adapter that
shows the document↔domain mapping boundary a fork follows. Mirrors the existing outbound adapters
(notification / cache-redis / httpclient / objectstorage / fileserver).
`dev.caskeleton.adapter.outbound.mongo` 패키지의 opt-in Spring Data MongoDB 인프라 모듈이다.
템플릿 production 코드에 가짜 비즈니스 `Example*` 타입을 두지 않고, 실제 프로젝트가 자신의
document/repository/mapper와 application 또는 domain port 구현을 추가할 수 있는 구성 경계만
제공한다.
The allowed/forbidden dependency policy is owned by `src/build.gradle`'s
`allowedProjectDependencies['adapter:outbound:persistence-mongo']` (SSOT). Module rules live in
[CLAUDE.md](CLAUDE.md); this document records the **design rationale** lifted out of the code
comments.
## 활성화
## Scope — deliberately lightweight
기본값은 비활성이다.
This module is **scaffolding, not a full persistence implementation**. It demonstrates *how* a fork
adds MongoDB-backed storage; it does **not** reimplement idempotency, outbox, or distributed lock on
Mongo (those stay JPA-only in `adapter:outbound:persistence-jpa`). There is no `application-core`
port here on purpose — the demonstrative example is entirely self-contained inside the adapter
package so the skeleton stays decoupled and copy-paste-forkable.
```properties
ca-skeleton.persistence-mongo.enabled=true
spring.data.mongodb.uri=mongodb://localhost:27017/portfolio
```
## Module overview
활성화 시 `MongoPersistenceConfig`가 Spring Boot의 Mongo client 및 data auto-configuration을
명시적으로 가져와 `MongoClient``MongoTemplate`을 구성한다. repository scanning은 템플릿이
임의로 소유하지 않는다. 실제 consumer가 자신의 repository package와 composition을 명시해야
한다.
An **opt-in** MongoDB module placed behind Spring Data MongoDB:
Mongo starter는 classpath만으로도 Boot auto-configuration 후보를 등록하므로 config의 조건만으로는
기본 비활성을 보장할 수 없다. `MongoOptInAutoConfigurationImportFilter`가 Boot 4의 sync/reactive
client, data, repository, health, metrics Mongo auto-configuration을 default/false에서 후보군에서
제외한다. 필터는 Boot 4가 `AutoConfigurationImportFilter`를 찾는 `META-INF/spring.factories`
등록되어 있으며, `enabled=true`일 때는 후보를 그대로 허용한다.
- `MongoPersistenceConfig` re-imports the Mongo auto-configuration with `@ImportAutoConfiguration`
(`MongoAutoConfiguration`, `DataMongoAutoConfiguration`, `DataMongoRepositoriesAutoConfiguration`)
and enables the repositories with `@EnableMongoRepositories(basePackageClasses = …)` scoped to this
package — but only when `ca-skeleton.persistence-mongo.enabled=true`. `@ImportAutoConfiguration` is
an explicit import unaffected by `spring.autoconfigure.exclude`, so the driver never connects when
the module is merely on the classpath. This mirrors ha-tmpl's `MongoPersistenceConfig`.
- The connection URI is read from Spring's standard `spring.data.mongodb.uri` (owned by Spring
Boot's `MongoProperties`). The module's own `MongoPersistenceProperties`
(`ca-skeleton.persistence-mongo.*`) owns only the `enabled` opt-in switch and a demonstrative
`database` name.
`MongoPersistenceProperties`는 모듈 opt-in만 소유한다. URI, database, credential은 Spring의
표준 `spring.data.mongodb.*` 설정을 사용한다.
Selector: `ca-skeleton.persistence-mongo.enabled=true` (default `false`).
## 의존성 경계
## The demonstrative example (document↔domain boundary)
- production project dependency 없음
- Spring Boot MongoDB starter와 configuration processor만 사용
- JPA persistence adapter 및 다른 adapter와 의존 관계 없음
- idempotency, outbox, distributed lock은 기존 JPA adapter 책임을 유지
- `ExampleRecord` — a small, self-contained "domain" value (NOT a real domain type, NOT an
`application-core` type).
- `ExampleMongoDocument` — the `@Document` persistence shape (`@Id`, `@Field` BSON names), kept
separate from the domain value exactly like a JPA entity is kept separate from its aggregate.
- `ExampleMongoRepository extends MongoRepository<ExampleMongoDocument, String>` — CRUD plus a
derived-query method (`findByNameIgnoreCase`) demonstrating Spring Data query derivation.
- `ExampleMongoMapper` — a pure, package-private static translator (`toDomain` / `toDocument`),
trivially unit-testable without a running MongoDB.
- `ExampleMongoRepositoryAdapter` — maps at the edge so the document type never leaks to callers;
the exact pattern a fork follows for a real aggregate/port.
## 검증
**How a fork replaces this:** swap `ExampleMongoDocument`/`ExampleMongoRepository` for a real
document + repository (renaming the collection and fields), map to the fork's real aggregate in
`ExampleMongoMapper`, and — if the fork wants a framework-neutral seam — implement an
`application-core` port from the adapter. `MongoPersistenceConfig` keeps working unchanged.
`MongoPersistenceConfigTest`는 다음을 검증한다.
## Tests
- `ExampleMongoMapperTest` — pure document↔domain mapping round-trip; no MongoDB needed.
- `ExampleMongoRepositoryIT` — real save/findById/derived-query round-trip against Testcontainers
MongoDB (a core `GenericContainer` running `mongo:7.0`); the repository proxy is built directly
with `MongoRepositoryFactory` so no full Spring context is required. Skipped automatically when
Docker is unavailable (`@Testcontainers(disabledWithoutDocker = true)`).
- 실제 `@EnableAutoConfiguration` context의 기본/false 모드에서 Mongo 인프라가 생성되지 않는다.
- enable flag가 typed properties에 바인딩된다.
- enabled 모드는 mock `MongoClient`로 네트워크 없이 실제 `MongoTemplate`을 생성한다.
- `Example` production bean이 존재하지 않는다.
```bash
cd src
./gradlew :adapter:outbound:persistence-mongo:check
./gradlew :adapter:outbound:persistence-mongo:check --console=plain
```
@@ -1,27 +1,13 @@
// Driven adapter: NoSQL persistence scaffolding via Spring Data MongoDB. This is a LIGHTWEIGHT,
// opt-in skeleton — it wires the Mongo driver + a demonstrative document/repository/adapter that
// shows the document<->domain mapping boundary a fork would follow. It does NOT reimplement
// idempotency/outbox/lock on Mongo (those stay JPA-only). Mongo auto-configuration is imported and
// the repositories enabled ONLY when ca-skeleton.persistence-mongo.enabled=true
// (MongoPersistenceConfig), so the driver never connects when the module is merely on the classpath.
// Driven adapter: opt-in Spring Data MongoDB infrastructure. This leaf owns only enablement and
// Mongo client/template auto-configuration; consuming projects add real documents, repositories,
// mappings, and ports without shipping a fake business domain in the template.
//
// spring-boot-starter-data-mongodb's version is managed by the Spring Boot BOM (applied to every
// module in src/build.gradle), so no module-scoped platform is needed. The Testcontainers MongoDB
// integration test uses the BOM-managed Testcontainers, and is skipped when Docker is unavailable.
description = 'Outbound adapter: NoSQL persistence scaffolding (Spring Data MongoDB)'
// module in src/build.gradle), so no module-scoped platform is needed.
description = 'Outbound adapter: opt-in Spring Data MongoDB infrastructure'
dependencies {
implementation project(':application-core')
implementation project(':shared-contract')
implementation 'org.springframework.boot:spring-boot-starter-data-mongodb'
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
// test-only: Testcontainers MongoDB integration test for the repository round-trip. Uses the core
// GenericContainer (no dedicated module) so the repository save/find runs against a real MongoDB
// when Docker is available and is skipped (disabledWithoutDocker) otherwise — mirroring the
// object-storage module's MinIO integration test.
testImplementation 'org.testcontainers:testcontainers'
testImplementation 'org.testcontainers:testcontainers-junit-jupiter'
}
@@ -6,9 +6,6 @@ ch.qos.logback:logback-classic:1.5.21=compileClasspath,runtimeClasspath,testComp
ch.qos.logback:logback-core:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor
com.github.docker-java:docker-java-api:3.7.0=testCompileClasspath,testRuntimeClasspath
com.github.docker-java:docker-java-transport-zerodep:3.7.0=testCompileClasspath,testRuntimeClasspath
com.github.docker-java:docker-java-transport:3.7.0=testCompileClasspath,testRuntimeClasspath
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,testCompileClasspath
@@ -38,9 +35,7 @@ com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath
commons-beanutils:commons-beanutils:1.11.0=checkstyle
commons-codec:commons-codec:1.19.0=testCompileClasspath,testRuntimeClasspath
commons-collections:commons-collections:3.2.2=checkstyle
commons-io:commons-io:2.20.0=testCompileClasspath,testRuntimeClasspath
commons-io:commons-io:2.21.0=spotbugs
commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
info.picocli:picocli:4.7.7=checkstyle
@@ -55,14 +50,12 @@ javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor
jaxen:jaxen:2.0.0=spotbugs
net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath
net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath
net.java.dev.jna:jna:5.18.1=testCompileClasspath,testRuntimeClasspath
net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs
org.antlr:antlr4-runtime:4.13.2=checkstyle
org.apache.bcel:bcel:6.12.0=spotbugs
org.apache.commons:commons-compress:1.28.0=testCompileClasspath,testRuntimeClasspath
org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs,testCompileClasspath,testRuntimeClasspath
org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs
org.apache.commons:commons-text:1.15.0=spotbugs
org.apache.commons:commons-text:1.3=checkstyle
org.apache.httpcomponents:httpclient:4.5.13=checkstyle
@@ -88,7 +81,6 @@ org.codehaus.plexus:plexus-utils:3.3.0=checkstyle
org.dom4j:dom4j:2.2.0=spotbugs
org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath
org.javassist:javassist:3.28.0-GA=checkstyle
org.jetbrains:annotations:17.0.0=testCompileClasspath,testRuntimeClasspath
org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath
@@ -119,7 +111,6 @@ org.ow2.asm:asm:9.10.1=spotbugs
org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath
org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor
org.reflections:reflections:0.10.2=checkstyle
org.rnorth.duct-tape:duct-tape:1.0.8=testCompileClasspath,testRuntimeClasspath
org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath
org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
@@ -166,8 +157,6 @@ org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath
org.springframework:spring-tx:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-web:7.0.1=testCompileClasspath,testRuntimeClasspath
org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath
org.testcontainers:testcontainers-junit-jupiter:2.0.2=testCompileClasspath,testRuntimeClasspath
org.testcontainers:testcontainers:2.0.2=testCompileClasspath,testRuntimeClasspath
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath
org.yaml:snakeyaml:2.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
@@ -1,49 +0,0 @@
package dev.caskeleton.adapter.outbound.mongo;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
import org.springframework.data.mongodb.core.mapping.Field;
/**
* MongoDB document model for the demonstrative {@link ExampleRecord}. Kept separate from the
* "domain" value exactly like a JPA entity is kept separate from its aggregate — this is the
* persistence shape (BSON field names, {@code @Id}), not the domain shape.
*
* <p>Modelled as a mutable JavaBean because that is the least-surprising shape for Spring Data
* MongoDB's mapping (no-arg construct + field population). A fork renames the collection and fields
* to match its real document.
*/
@Document(collection = "ca_skeleton_examples")
public class ExampleMongoDocument {
@Id private String id;
private String name;
@Field("qty")
private int quantity;
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getQuantity() {
return quantity;
}
public void setQuantity(int quantity) {
this.quantity = quantity;
}
}
@@ -1,27 +0,0 @@
package dev.caskeleton.adapter.outbound.mongo;
/**
* Translation between the demonstrative {@link ExampleRecord} "domain" value and its {@link
* ExampleMongoDocument} persistence shape. Package-private and static: mapping is a pure function
* with no framework dependency, so it is trivially unit-testable without a running MongoDB (see
* {@code ExampleMongoMapperTest}).
*
* <p>This is the boundary a fork keeps: the repository/adapter never leak the document type
* outward; callers receive only the domain value.
*/
final class ExampleMongoMapper {
private ExampleMongoMapper() {}
static ExampleRecord toDomain(ExampleMongoDocument document) {
return new ExampleRecord(document.getId(), document.getName(), document.getQuantity());
}
static ExampleMongoDocument toDocument(ExampleRecord record) {
ExampleMongoDocument document = new ExampleMongoDocument();
document.setId(record.id());
document.setName(record.name());
document.setQuantity(record.quantity());
return document;
}
}
@@ -1,16 +0,0 @@
package dev.caskeleton.adapter.outbound.mongo;
import java.util.List;
import org.springframework.data.mongodb.repository.MongoRepository;
/**
* Spring Data MongoDB repository for {@link ExampleMongoDocument}. Extending {@link
* MongoRepository} supplies the CRUD surface (save / findById / delete / count …); the
* derived-query method below demonstrates Spring Data's query derivation. A fork replaces the
* document type parameter and the derived queries with its own.
*/
public interface ExampleMongoRepository extends MongoRepository<ExampleMongoDocument, String> {
/** Derived query — case-insensitive lookup by the {@code name} field. */
List<ExampleMongoDocument> findByNameIgnoreCase(String name);
}
@@ -1,51 +0,0 @@
package dev.caskeleton.adapter.outbound.mongo;
import java.util.List;
import java.util.Optional;
/**
* Demonstrative repository adapter: the boundary between the Spring Data {@link
* ExampleMongoRepository} (documents) and the module-local {@link ExampleRecord} "domain" value.
* Every method maps at the edge via {@link ExampleMongoMapper}, so the document type never leaks to
* callers — the exact pattern a fork follows for a real aggregate/port.
*
* <p>Deliberately a plain class (no {@code @Component}); {@link MongoPersistenceConfig} assembles
* it as a bean only when the module is opted in, mirroring the object-storage / file-server
* adapters.
*/
public class ExampleMongoRepositoryAdapter {
private final ExampleMongoRepository repository;
public ExampleMongoRepositoryAdapter(ExampleMongoRepository repository) {
this.repository = repository;
}
/** Inserts or updates the document for {@code record} and returns the persisted value. */
public ExampleRecord save(ExampleRecord record) {
ExampleMongoDocument saved = repository.save(ExampleMongoMapper.toDocument(record));
return ExampleMongoMapper.toDomain(saved);
}
/** Reads by id, mapping the document back to the domain value; {@code empty()} when absent. */
public Optional<ExampleRecord> findById(String id) {
return repository.findById(id).map(ExampleMongoMapper::toDomain);
}
/** Derived-query lookup by name, mapped to domain values. */
public List<ExampleRecord> findByName(String name) {
return repository.findByNameIgnoreCase(name).stream()
.map(ExampleMongoMapper::toDomain)
.toList();
}
/** Idempotent delete by id. */
public void deleteById(String id) {
repository.deleteById(id);
}
/** Total document count in the collection. */
public long count() {
return repository.count();
}
}
@@ -1,12 +0,0 @@
package dev.caskeleton.adapter.outbound.mongo;
/**
* A small, self-contained "domain" value used purely to demonstrate the document&lt;-&gt;domain
* mapping boundary. It is intentionally NOT a real domain type and NOT an {@code application-core}
* type — the persistence-mongo module is lightweight scaffolding, so the example lives entirely
* inside the adapter package.
*
* <p>A fork replaces this with its real aggregate (typically owned by {@code domain-core}) and maps
* to it in {@link ExampleMongoMapper} exactly as shown here.
*/
public record ExampleRecord(String id, String name, int quantity) {}
@@ -0,0 +1,59 @@
package dev.caskeleton.adapter.outbound.mongo;
import java.util.Set;
import org.springframework.boot.autoconfigure.AutoConfigurationImportFilter;
import org.springframework.boot.autoconfigure.AutoConfigurationMetadata;
import org.springframework.context.EnvironmentAware;
import org.springframework.core.env.Environment;
/**
* Prevents Spring Boot's classpath-driven Mongo auto-configurations from bypassing this module's
* explicit opt-in property.
*
* <p>The Mongo starter contributes its auto-configurations directly through Boot's import metadata.
* Consequently, conditioning only {@link MongoPersistenceConfig} is insufficient: a normal
* {@code @EnableAutoConfiguration} application would still create a client and template. This
* filter keeps all Boot 4 sync, reactive, repository, health, and metrics Mongo imports out of the
* candidate set until {@code ca-skeleton.persistence-mongo.enabled=true}.
*/
public final class MongoOptInAutoConfigurationImportFilter
implements AutoConfigurationImportFilter, EnvironmentAware {
private static final String ENABLE_PROPERTY = "ca-skeleton.persistence-mongo.enabled";
private static final Set<String> MONGO_AUTO_CONFIGURATIONS =
Set.of(
"org.springframework.boot.mongodb.autoconfigure.MongoAutoConfiguration",
"org.springframework.boot.mongodb.autoconfigure.MongoReactiveAutoConfiguration",
"org.springframework.boot.mongodb.autoconfigure.health.MongoHealthContributorAutoConfiguration",
"org.springframework.boot.mongodb.autoconfigure.health.MongoReactiveHealthContributorAutoConfiguration",
"org.springframework.boot.mongodb.autoconfigure.metrics.MongoMetricsAutoConfiguration",
"org.springframework.boot.data.mongodb.autoconfigure.DataMongoAutoConfiguration",
"org.springframework.boot.data.mongodb.autoconfigure.DataMongoReactiveAutoConfiguration",
"org.springframework.boot.data.mongodb.autoconfigure.DataMongoReactiveRepositoriesAutoConfiguration",
"org.springframework.boot.data.mongodb.autoconfigure.DataMongoRepositoriesAutoConfiguration");
private Environment environment;
@Override
public boolean[] match(
String[] autoConfigurationClasses, AutoConfigurationMetadata autoConfigurationMetadata) {
boolean enabled =
environment != null
&& "true".equalsIgnoreCase(environment.getProperty(ENABLE_PROPERTY, "false"));
boolean[] matches = new boolean[autoConfigurationClasses.length];
for (int index = 0; index < autoConfigurationClasses.length; index++) {
String autoConfigurationClass = autoConfigurationClasses[index];
matches[index] =
enabled
|| autoConfigurationClass == null
|| !MONGO_AUTO_CONFIGURATIONS.contains(autoConfigurationClass);
}
return matches;
}
@Override
public void setEnvironment(Environment environment) {
this.environment = environment;
}
}
@@ -4,26 +4,18 @@ import org.springframework.boot.autoconfigure.ImportAutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.data.mongodb.autoconfigure.DataMongoAutoConfiguration;
import org.springframework.boot.data.mongodb.autoconfigure.DataMongoRepositoriesAutoConfiguration;
import org.springframework.boot.mongodb.autoconfigure.MongoAutoConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.mongodb.repository.config.EnableMongoRepositories;
/**
* Opt-in wiring for the MongoDB persistence scaffolding, mirroring ha-tmpl's {@code
* MongoPersistenceConfig}. The whole configuration — and therefore the Mongo driver connection,
* repositories, and the demonstrative adapter bean — activates ONLY when {@code
* ca-skeleton.persistence-mongo.enabled=true}.
* Opt-in wiring for MongoDB infrastructure. The configuration, Mongo client, and template activate
* only when {@code ca-skeleton.persistence-mongo.enabled=true}.
*
* <p>{@link ImportAutoConfiguration} is an <em>explicit</em> import that is not affected by {@code
* spring.autoconfigure.exclude}, so re-importing the Mongo auto-configuration here cleanly turns
* MongoDB on for the opted-in profile without the driver ever connecting when the module is merely
* on the classpath. {@link EnableMongoRepositories} is scoped to this package via {@code
* basePackageClasses} so repository scanning never reaches beyond the skeleton.
*
* <p>A fork replaces {@link ExampleMongoRepository}/{@link ExampleMongoDocument} with its real
* document + repository and this config keeps working unchanged.
* on the classpath. A consuming project adds its document, repository, and mapping adapter in this
* leaf and explicitly owns any repository scanning it requires.
*/
@Configuration(proxyBeanMethods = false)
@ConditionalOnProperty(
@@ -31,21 +23,5 @@ import org.springframework.data.mongodb.repository.config.EnableMongoRepositorie
name = "enabled",
havingValue = "true")
@EnableConfigurationProperties(MongoPersistenceProperties.class)
@ImportAutoConfiguration({
MongoAutoConfiguration.class,
DataMongoAutoConfiguration.class,
DataMongoRepositoriesAutoConfiguration.class
})
@EnableMongoRepositories(basePackageClasses = ExampleMongoRepository.class)
public class MongoPersistenceConfig {
/**
* Assembles the demonstrative adapter as a bean (the adapter is a plain class), mirroring how the
* object-storage / file-server modules assemble their adapters. A fork swaps this for its real
* repository adapter.
*/
@Bean
ExampleMongoRepositoryAdapter exampleMongoRepositoryAdapter(ExampleMongoRepository repository) {
return new ExampleMongoRepositoryAdapter(repository);
}
}
@ImportAutoConfiguration({MongoAutoConfiguration.class, DataMongoAutoConfiguration.class})
public class MongoPersistenceConfig {}
@@ -3,14 +3,13 @@ package dev.caskeleton.adapter.outbound.mongo;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* Typed settings for the MongoDB persistence scaffolding, bound from {@code
* ca-skeleton.persistence-mongo.*}. Bound as a mutable JavaBean (not a record) so a fork can leave
* any subset of fields unset and inherit the defaults below.
* Typed enablement settings for the MongoDB infrastructure, bound from {@code
* ca-skeleton.persistence-mongo.*}.
*
* <p>The Mongo <b>connection URI</b> is intentionally NOT modelled here — it is read from Spring's
* own standard {@code spring.data.mongodb.uri} (owned by Spring Boot's {@code MongoProperties}),
* which keeps credentials/host wiring in the one place operators already expect. This class only
* owns the module's own opt-in switch and a demonstrative logical-database name.
* which keeps credentials, host, and database wiring in the one place operators already expect.
* This class owns only the module's opt-in switch.
*/
@ConfigurationProperties(prefix = "ca-skeleton.persistence-mongo")
public class MongoPersistenceProperties {
@@ -22,13 +21,6 @@ public class MongoPersistenceProperties {
*/
private boolean enabled = false;
/**
* Demonstrative logical database name. This is scaffolding metadata a fork may surface in
* diagnostics; the effective database is whatever {@code spring.data.mongodb.uri} (or {@code
* spring.data.mongodb.database}) resolves to.
*/
private String database = "ca_skeleton";
public boolean isEnabled() {
return enabled;
}
@@ -36,12 +28,4 @@ public class MongoPersistenceProperties {
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public String getDatabase() {
return database;
}
public void setDatabase(String database) {
this.database = database;
}
}
@@ -0,0 +1,2 @@
org.springframework.boot.autoconfigure.AutoConfigurationImportFilter=\
dev.caskeleton.adapter.outbound.mongo.MongoOptInAutoConfigurationImportFilter
@@ -1,44 +0,0 @@
package dev.caskeleton.adapter.outbound.mongo;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
/**
* Pure document&lt;-&gt;domain mapping contract for {@link ExampleMongoMapper} — no MongoDB needed.
*/
class ExampleMongoMapperTest {
@Test
void toDocumentCopiesEveryField() {
ExampleRecord record = new ExampleRecord("id-1", "widget", 7);
ExampleMongoDocument document = ExampleMongoMapper.toDocument(record);
assertThat(document.getId()).isEqualTo("id-1");
assertThat(document.getName()).isEqualTo("widget");
assertThat(document.getQuantity()).isEqualTo(7);
}
@Test
void toDomainCopiesEveryField() {
ExampleMongoDocument document = new ExampleMongoDocument();
document.setId("id-2");
document.setName("gadget");
document.setQuantity(3);
ExampleRecord record = ExampleMongoMapper.toDomain(document);
assertThat(record).isEqualTo(new ExampleRecord("id-2", "gadget", 3));
}
@Test
void roundTripThroughDocumentPreservesTheDomainValue() {
ExampleRecord original = new ExampleRecord("id-3", "sprocket", 42);
ExampleRecord roundTripped =
ExampleMongoMapper.toDomain(ExampleMongoMapper.toDocument(original));
assertThat(roundTripped).isEqualTo(original);
}
}
@@ -1,87 +0,0 @@
package dev.caskeleton.adapter.outbound.mongo;
import static org.assertj.core.api.Assertions.assertThat;
import com.mongodb.client.MongoClient;
import com.mongodb.client.MongoClients;
import java.util.Optional;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.SimpleMongoClientDatabaseFactory;
import org.springframework.data.mongodb.repository.support.MongoRepositoryFactory;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.wait.strategy.Wait;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;
/**
* Real MongoDB round-trip for {@link ExampleMongoRepositoryAdapter} against a Testcontainers
* MongoDB. The Spring Data repository proxy is built directly with {@link MongoRepositoryFactory}
* over a {@link MongoTemplate} — no full Spring context needed, mirroring how the object-storage
* MinIO IT constructs its adapter by hand. Skipped automatically when Docker is unavailable ({@code
* disabledWithoutDocker = true}); the pure mapping is covered separately by {@link
* ExampleMongoMapperTest}.
*/
@Testcontainers(disabledWithoutDocker = true)
class ExampleMongoRepositoryIT {
private static final int MONGO_PORT = 27017;
private static final String DATABASE = "ca_skeleton_it";
@Container
@SuppressWarnings("resource")
static final GenericContainer<?> MONGO =
new GenericContainer<>(DockerImageName.parse("mongo:7.0"))
.withExposedPorts(MONGO_PORT)
.waitingFor(Wait.forLogMessage("(?i).*waiting for connections.*", 1));
private MongoClient client;
private ExampleMongoRepositoryAdapter adapter;
@BeforeEach
void setUp() {
String uri = "mongodb://" + MONGO.getHost() + ":" + MONGO.getMappedPort(MONGO_PORT);
client = MongoClients.create(uri);
MongoTemplate template =
new MongoTemplate(new SimpleMongoClientDatabaseFactory(client, DATABASE));
ExampleMongoRepository repository =
new MongoRepositoryFactory(template).getRepository(ExampleMongoRepository.class);
repository.deleteAll();
adapter = new ExampleMongoRepositoryAdapter(repository);
}
@AfterEach
void tearDown() {
if (client != null) {
client.close();
}
}
@Test
void savesAndReadsBackThroughTheDomainBoundary() {
ExampleRecord saved = adapter.save(new ExampleRecord("it-1", "widget", 5));
assertThat(saved).isEqualTo(new ExampleRecord("it-1", "widget", 5));
Optional<ExampleRecord> found = adapter.findById("it-1");
assertThat(found).contains(new ExampleRecord("it-1", "widget", 5));
}
@Test
void findByNameUsesTheDerivedQueryCaseInsensitively() {
adapter.save(new ExampleRecord("it-2", "Gadget", 1));
adapter.save(new ExampleRecord("it-3", "gadget", 2));
assertThat(adapter.findByName("GADGET"))
.extracting(ExampleRecord::id)
.containsExactlyInAnyOrder("it-2", "it-3");
}
@Test
void findByIdIsEmptyForAnAbsentId() {
assertThat(adapter.findById("absent")).isEmpty();
assertThat(adapter.count()).isZero();
}
}
@@ -0,0 +1,84 @@
package dev.caskeleton.adapter.outbound.mongo;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import com.mongodb.client.MongoClient;
import java.util.Arrays;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.mongodb.core.MongoTemplate;
class MongoPersistenceConfigTest {
private final ApplicationContextRunner runner =
new ApplicationContextRunner()
.withUserConfiguration(BootAutoConfigurationApp.class, MongoPersistenceConfig.class)
.withPropertyValues("spring.data.mongodb.database=portfolio");
@Test
void disabledByDefaultCreatesNoMongoInfrastructure() {
runner.run(
context -> {
assertThat(context).hasNotFailed();
assertThat(context).doesNotHaveBean(MongoClient.class);
assertThat(context).doesNotHaveBean(MongoTemplate.class);
});
}
@Test
void explicitlyDisabledCreatesNoMongoInfrastructure() {
runner
.withPropertyValues("ca-skeleton.persistence-mongo.enabled=false")
.run(
context -> {
assertThat(context).hasNotFailed();
assertThat(context).doesNotHaveBean(MongoClient.class);
assertThat(context).doesNotHaveBean(MongoTemplate.class);
});
}
@Test
void enableFlagBindsThroughTypedProperties() {
new ApplicationContextRunner()
.withUserConfiguration(PropertiesOnly.class)
.withPropertyValues("ca-skeleton.persistence-mongo.enabled=true")
.run(
context -> {
assertThat(context).hasNotFailed();
MongoPersistenceProperties properties =
context.getBean(MongoPersistenceProperties.class);
assertThat(properties.isEnabled()).isTrue();
});
}
@Test
void enabledModeCreatesMongoTemplateWithoutExampleDomainBeans() {
MongoClient client = mock(MongoClient.class);
runner
.withBean(MongoClient.class, () -> client)
.withPropertyValues(
"ca-skeleton.persistence-mongo.enabled=true", "spring.data.mongodb.database=portfolio")
.run(
context -> {
assertThat(context).hasNotFailed();
assertThat(context).hasSingleBean(MongoTemplate.class);
assertThat(
Arrays.stream(context.getBeanDefinitionNames())
.filter(name -> name.contains("example")))
.isEmpty();
});
}
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(MongoPersistenceProperties.class)
static class PropertiesOnly {}
@Configuration(proxyBeanMethods = false)
@EnableAutoConfiguration
static class BootAutoConfigurationApp {}
}