Skip to content

Commit 6c39671

Browse files
authored
Enforce production database boundaries (#16)
1 parent 267eb00 commit 6c39671

17 files changed

Lines changed: 516 additions & 3 deletions

File tree

.github/workflows/ci.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,5 +41,14 @@ jobs:
4141
- name: Build and test
4242
run: ./mvnw --batch-mode --no-transfer-progress clean verify
4343

44+
- name: Confirm real database contract ran
45+
run: |
46+
report="target/surefire-reports/TEST-com.aggregation.service.integration.RealDatabasesIntegrationTest.xml"
47+
test -f "$report"
48+
grep -q 'tests="1"' "$report"
49+
grep -q 'errors="0"' "$report"
50+
grep -q 'skipped="0"' "$report"
51+
grep -q 'failures="0"' "$report"
52+
4453
- name: Build Docker image
4554
run: docker build --tag user-aggregation-service:ci .

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,20 @@ All notable changes to this project are documented in this file.
44

55
## Unreleased
66

7+
### Added
8+
9+
- Paginated `/api/v2/users` contract with explicit source provenance
10+
- Ports and adapters for independent PostgreSQL and MongoDB persistence models
11+
- ArchUnit dependency rules and real PostgreSQL/MongoDB Testcontainers coverage
12+
- Driver-level timeouts and per-source query metrics
13+
- MIT License
14+
15+
### Changed
16+
17+
- Bounded v1 reads without changing the existing JSON response fields
18+
- Standard HTTP failures now preserve their `400`, `404`, `405`, and `415` statuses
19+
- User identities are now unambiguous by source and source-local ID
20+
721
## 1.0.0 - 2026-07-28
822

923
### Added

CONTRIBUTING.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
## Development requirements
44

55
- JDK 21
6-
- Docker with MongoDB available on `localhost:27017`
6+
- Docker Engine for the real PostgreSQL and MongoDB contract test
7+
- MongoDB available on `localhost:27017` for the lightweight integration tests
78

89
Run the full local quality gate before opening a pull request:
910

@@ -18,6 +19,7 @@ On Windows, use `.\mvnw.cmd` for Maven commands.
1819

1920
- Keep REST paths and successful response fields backward compatible.
2021
- Add or update tests for validation, caching, failure handling, persistence, or ordering changes.
22+
- Preserve the ports-and-adapters dependency rules enforced by ArchUnit.
2123
- Keep database changes in a new Flyway migration; never rewrite an applied migration.
2224
- Do not commit credentials, `.env`, database files, logs, or generated build output.
2325
- Document externally visible changes in `CHANGELOG.md`.

README.md

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ This is a portfolio service with production-oriented failure, validation, migrat
1313
- Concurrent PostgreSQL and MongoDB aggregation on a dedicated bounded executor
1414
- A provenance-aware, paginated v2 API with deterministic cross-source ordering
1515
- Deterministic `503 Service Unavailable` responses when either source fails or times out
16+
- JDBC query, connection-pool, and MongoDB driver timeouts derived from bounded configuration
17+
- Per-source query duration metrics tagged by source and outcome
1618
- Caffeine query caching with write-triggered invalidation
1719
- Request validation and explicit `201 Created`, `400`, `409`, and `503` API contracts
1820
- Flyway-managed relational schema migrations
@@ -72,7 +74,8 @@ docker compose down --volumes
7274
| Dependency | Version | Notes |
7375
| --- | --- | --- |
7476
| JDK | 21 | The Maven compiler release and CI runtime |
75-
| MongoDB | 6 or newer | Required at `localhost:27017` by the integration tests |
77+
| Docker | Current Docker Engine | Runs the real PostgreSQL and MongoDB contract test |
78+
| MongoDB | 6 or newer | Required at `localhost:27017` by the lightweight integration tests |
7679
| Maven | Wrapper-provided | A separate Maven installation is not required |
7780

7881
Start a disposable MongoDB for local tests:
@@ -94,6 +97,7 @@ PowerShell:
9497
```
9598

9699
GitHub Actions runs the same command on Java 21 with a MongoDB service and separately verifies that the Docker image builds.
100+
It also fails if the Testcontainers contract test against real PostgreSQL and MongoDB is skipped.
97101

98102
Tagged releases publish the executable JAR, SHA-256 checksum, CycloneDX SBOM,
99103
and GitHub build-provenance attestation.
@@ -123,6 +127,7 @@ The default relational database is in-memory H2. Omit the `demo` profile to star
123127
| `AGGREGATION_PRIMARY_JDBC_PASSWORD` | empty | Relational password |
124128
| `AGGREGATION_MONGODB_URI` | `mongodb://localhost:27017/users` | MongoDB connection string |
125129
| `AGGREGATION_QUERY_TIMEOUT` | `2s` | Maximum wait for each source query |
130+
| `AGGREGATION_CONNECTION_TIMEOUT_MS` | `2000` | Maximum wait for a pooled JDBC connection |
126131
| `AGGREGATION_EXECUTOR_CORE_POOL_SIZE` | `4` | Core aggregation worker count |
127132
| `AGGREGATION_EXECUTOR_MAX_POOL_SIZE` | `8` | Maximum aggregation worker count |
128133
| `AGGREGATION_EXECUTOR_QUEUE_CAPACITY` | `100` | Bounded pending-query capacity |
@@ -189,6 +194,11 @@ HTTP adapters: /api/users and /api/v2/users
189194
JPA entity + Flyway Mongo document
190195
```
191196

197+
The core dependency direction is enforced by ArchUnit. Application/domain code
198+
cannot depend on controllers, HTTP DTOs, or persistence adapters. CI additionally
199+
starts disposable PostgreSQL and MongoDB containers and verifies the real Flyway,
200+
JPA, MongoDB, and aggregation contract together.
201+
192202
## Operations
193203

194204
| Concern | Entry point |
@@ -199,6 +209,8 @@ HTTP adapters: /api/users and /api/v2/users
199209
| Local image health | Docker health check against `/actuator/health` |
200210

201211
Health details are not exposed to unauthenticated callers. Logs contain source failures and server-side correlation IDs, but API errors do not expose exception messages.
212+
After a source query, `/actuator/metrics/user.aggregation.source.query` exposes
213+
duration and count with bounded `source` and `outcome` tags.
202214

203215
## Current boundaries
204216

@@ -213,7 +225,9 @@ See [SECURITY.md](SECURITY.md) for supported versions and private vulnerability
213225

214226
## Contributing
215227

216-
See [CONTRIBUTING.md](CONTRIBUTING.md). Pull requests must pass Java tests, the real MongoDB integration tests, the package build, and the Docker image build.
228+
See [CONTRIBUTING.md](CONTRIBUTING.md). Pull requests must pass Java tests, the
229+
real PostgreSQL and MongoDB Testcontainers contract, architecture rules, the
230+
package build, and the Docker image build.
217231

218232
## License
219233

pom.xml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323

2424
<properties>
2525
<java.version>21</java.version>
26+
<archunit.version>1.4.2</archunit.version>
2627
<springdoc.version>2.8.17</springdoc.version>
2728
</properties>
2829

@@ -62,6 +63,27 @@
6263
<artifactId>spring-boot-starter-test</artifactId>
6364
<scope>test</scope>
6465
</dependency>
66+
<dependency>
67+
<groupId>org.testcontainers</groupId>
68+
<artifactId>junit-jupiter</artifactId>
69+
<scope>test</scope>
70+
</dependency>
71+
<dependency>
72+
<groupId>org.testcontainers</groupId>
73+
<artifactId>postgresql</artifactId>
74+
<scope>test</scope>
75+
</dependency>
76+
<dependency>
77+
<groupId>org.testcontainers</groupId>
78+
<artifactId>mongodb</artifactId>
79+
<scope>test</scope>
80+
</dependency>
81+
<dependency>
82+
<groupId>com.tngtech.archunit</groupId>
83+
<artifactId>archunit-junit5</artifactId>
84+
<version>${archunit.version}</version>
85+
<scope>test</scope>
86+
</dependency>
6587
<dependency>
6688
<groupId>org.springdoc</groupId>
6789
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package com.aggregation.service.adapter.metrics;
2+
3+
import com.aggregation.service.application.port.UserSourceMetrics;
4+
import com.aggregation.service.domain.UserSource;
5+
import io.micrometer.core.instrument.MeterRegistry;
6+
import io.micrometer.core.instrument.Timer;
7+
import lombok.RequiredArgsConstructor;
8+
import org.springframework.stereotype.Component;
9+
10+
import java.util.concurrent.atomic.AtomicBoolean;
11+
12+
@Component
13+
@RequiredArgsConstructor
14+
public class MicrometerUserSourceMetrics implements UserSourceMetrics {
15+
public static final String QUERY_METRIC = "user.aggregation.source.query";
16+
17+
private final MeterRegistry meterRegistry;
18+
19+
@Override
20+
public QueryTimer start(UserSource source) {
21+
Timer.Sample sample = Timer.start(meterRegistry);
22+
AtomicBoolean stopped = new AtomicBoolean();
23+
return outcome -> {
24+
if (stopped.compareAndSet(false, true)) {
25+
sample.stop(Timer.builder(QUERY_METRIC)
26+
.description("User source query duration")
27+
.tag("source", source.apiValue())
28+
.tag("outcome", outcome.tagValue())
29+
.register(meterRegistry));
30+
}
31+
};
32+
}
33+
}

src/main/java/com/aggregation/service/application/UserAggregationService.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.aggregation.service.application;
22

33
import com.aggregation.service.application.port.UserReadSource;
4+
import com.aggregation.service.application.port.UserSourceMetrics;
45
import com.aggregation.service.application.port.UserWriter;
56
import com.aggregation.service.config.properties.AggregationProperties;
67
import com.aggregation.service.domain.AggregatedUser;
@@ -22,6 +23,7 @@
2223
import java.util.concurrent.Executor;
2324
import java.util.concurrent.RejectedExecutionException;
2425
import java.util.concurrent.TimeUnit;
26+
import java.util.concurrent.TimeoutException;
2527
import java.util.function.Supplier;
2628

2729
@Service
@@ -43,12 +45,14 @@ public class UserAggregationService {
4345
private final List<UserReadSource> readSources;
4446
private final Map<UserSource, UserReadSource> readSourceByType;
4547
private final UserWriter userWriter;
48+
private final UserSourceMetrics sourceMetrics;
4649
private final Executor aggregationExecutor;
4750
private final long queryTimeoutMillis;
4851

4952
public UserAggregationService(
5053
List<UserReadSource> readSources,
5154
UserWriter userWriter,
55+
UserSourceMetrics sourceMetrics,
5256
@Qualifier("aggregationExecutor") Executor aggregationExecutor,
5357
AggregationProperties properties) {
5458
this.readSources = readSources.stream()
@@ -57,6 +61,7 @@ public UserAggregationService(
5761
this.readSourceByType = indexReadSources(this.readSources);
5862
requireReadSource(UserSource.POSTGRESQL);
5963
this.userWriter = userWriter;
64+
this.sourceMetrics = sourceMetrics;
6065
this.aggregationExecutor = aggregationExecutor;
6166
this.queryTimeoutMillis = properties.getQueryTimeout().toMillis();
6267
}
@@ -153,16 +158,21 @@ private List<AggregatedUser> aggregate(UserSearchCriteria criteria, int limit) {
153158
private CompletableFuture<List<AggregatedUser>> querySource(
154159
UserSource source,
155160
Supplier<List<AggregatedUser>> query) {
161+
UserSourceMetrics.QueryTimer timer = sourceMetrics.start(source);
156162
try {
157163
return CompletableFuture
158164
.supplyAsync(query, aggregationExecutor)
159165
.orTimeout(queryTimeoutMillis, TimeUnit.MILLISECONDS)
160166
.handle((users, error) -> {
161167
if (error == null) {
168+
timer.stop(UserSourceMetrics.Outcome.SUCCESS);
162169
return List.copyOf(users);
163170
}
164171

165172
Throwable cause = unwrap(error);
173+
timer.stop(cause instanceof TimeoutException
174+
? UserSourceMetrics.Outcome.TIMEOUT
175+
: UserSourceMetrics.Outcome.FAILURE);
166176
log.error(
167177
"{} user query failed: {}",
168178
source.displayName(),
@@ -173,6 +183,7 @@ private CompletableFuture<List<AggregatedUser>> querySource(
173183
);
174184
});
175185
} catch (RejectedExecutionException exception) {
186+
timer.stop(UserSourceMetrics.Outcome.REJECTED);
176187
log.error("{} user query rejected: executor saturated", source.displayName());
177188
return CompletableFuture.failedFuture(
178189
new SourceUnavailableException(source.displayName(), exception)
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package com.aggregation.service.application.port;
2+
3+
import com.aggregation.service.domain.UserSource;
4+
5+
public interface UserSourceMetrics {
6+
7+
QueryTimer start(UserSource source);
8+
9+
enum Outcome {
10+
SUCCESS("success"),
11+
FAILURE("failure"),
12+
TIMEOUT("timeout"),
13+
REJECTED("rejected");
14+
15+
private final String tagValue;
16+
17+
Outcome(String tagValue) {
18+
this.tagValue = tagValue;
19+
}
20+
21+
public String tagValue() {
22+
return tagValue;
23+
}
24+
}
25+
26+
@FunctionalInterface
27+
interface QueryTimer {
28+
void stop(Outcome outcome);
29+
}
30+
31+
static UserSourceMetrics noop() {
32+
return source -> outcome -> {
33+
};
34+
}
35+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package com.aggregation.service.config;
2+
3+
import com.aggregation.service.config.properties.AggregationProperties;
4+
import org.springframework.boot.autoconfigure.mongo.MongoClientSettingsBuilderCustomizer;
5+
import org.springframework.boot.autoconfigure.orm.jpa.HibernatePropertiesCustomizer;
6+
import org.springframework.context.annotation.Bean;
7+
import org.springframework.context.annotation.Configuration;
8+
9+
import java.time.Duration;
10+
import java.util.concurrent.TimeUnit;
11+
12+
@Configuration(proxyBeanMethods = false)
13+
public class DatabaseTimeoutConfiguration {
14+
15+
@Bean
16+
HibernatePropertiesCustomizer jdbcQueryTimeout(AggregationProperties properties) {
17+
int timeoutMillis = Math.toIntExact(
18+
properties.getQueryTimeout().toMillis()
19+
);
20+
return hibernateProperties -> hibernateProperties.put(
21+
"jakarta.persistence.query.timeout",
22+
timeoutMillis
23+
);
24+
}
25+
26+
@Bean
27+
MongoClientSettingsBuilderCustomizer mongoDriverTimeouts(
28+
AggregationProperties properties) {
29+
Duration timeout = properties.getQueryTimeout();
30+
return builder -> builder
31+
.applyToClusterSettings(settings -> settings
32+
.serverSelectionTimeout(
33+
timeout.toMillis(),
34+
TimeUnit.MILLISECONDS
35+
))
36+
.applyToSocketSettings(settings -> settings
37+
.connectTimeout(
38+
timeout.toMillis(),
39+
TimeUnit.MILLISECONDS
40+
)
41+
.readTimeout(
42+
timeout.toMillis(),
43+
TimeUnit.MILLISECONDS
44+
));
45+
}
46+
}

src/main/java/com/aggregation/service/config/properties/AggregationProperties.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.aggregation.service.config.properties;
22

33
import jakarta.validation.Valid;
4+
import jakarta.validation.constraints.AssertTrue;
45
import jakarta.validation.constraints.Min;
56
import jakarta.validation.constraints.NotNull;
67
import lombok.Getter;
@@ -22,6 +23,14 @@ public class AggregationProperties {
2223
@Valid
2324
private ExecutorProperties executor = new ExecutorProperties();
2425

26+
@AssertTrue(message = "query-timeout must be between 1ms and 2147483647ms")
27+
public boolean isQueryTimeoutValid() {
28+
return queryTimeout != null
29+
&& !queryTimeout.isZero()
30+
&& !queryTimeout.isNegative()
31+
&& queryTimeout.toMillis() <= Integer.MAX_VALUE;
32+
}
33+
2534
@Getter
2635
@Setter
2736
public static class ExecutorProperties {

0 commit comments

Comments
 (0)