Swapping Adapters: PostgreSQL Without Touching the Core
TL;DR
Hexagonal architecture is a promise: you can replace an adapter without touching the core. In this part I keep that promise by swapping the in-memory repository for PostgreSQL, and I prove it with a contract test that both adapters must pass.
⭐ Code: github.com/cbuelvasc/orders-mcp · Part 3 of the series MCP in Production: Java & Go
| What | Choice |
|---|---|
| Database | PostgreSQL 17 |
| Access | JdbcClient (plain SQL, no ORM) |
| Migrations | Flyway (schema + demo seed) |
| Tests | Contract test + Testcontainers |
| Local dev | compose.yaml started by ./gradlew bootRun |
🔁 The swap
What changed per layer for the persistence swap:
| Layer | Files changed |
|---|---|
domain |
0 |
application |
0 |
adapter/in (MCP, REST) |
0 |
adapter/out |
new JdbcOrderRepository |
resources |
two Flyway migrations |
The only port change was a new save method, and that came from the cancelOrder feature in part 2, not from the database.
🧾 1. The port is the contract
/** Outbound port: implemented by driven adapters (PostgreSQL in production, in-memory in tests). */
public interface OrderRepositoryPort {
Optional<Order> findById(OrderId id);
List<Order> findRecentByCustomer(CustomerId customerId, int limit);
Order save(Order order);
}
🧪 2. A contract test every adapter must pass
This is the key idea of the post. Instead of testing each adapter in its own way, I write the expectations once, as an abstract test against the port:
/**
* Contract for the outbound port. EVERY adapter (in-memory, PostgreSQL, ...) must pass it.
* This is what makes swapping adapters safe.
*/
public abstract class OrderRepositoryContract {
protected abstract OrderRepositoryPort repository();
private static final Instant NOW = Instant.parse("2026-11-13T12:00:00Z");
@Test
void savesAndFindsAnOrder() {
var order = order("ORD-9001", "CUS-99", OrderStatus.PAID, NOW);
repository().save(order);
assertThat(repository().findById(order.id())).contains(order);
}
@Test
void returnsEmptyForUnknownOrders() {
assertThat(repository().findById(OrderId.of("ORD-9999999"))).isEmpty();
}
@Test
void listsTheMostRecentOrdersOfOneCustomerNewestFirst() {
repository().save(order("ORD-9101", "CUS-98", OrderStatus.PAID, NOW.minus(3, ChronoUnit.HOURS)));
repository().save(order("ORD-9102", "CUS-98", OrderStatus.PAID, NOW.minus(1, ChronoUnit.HOURS)));
repository().save(order("ORD-9103", "CUS-98", OrderStatus.PAID, NOW.minus(2, ChronoUnit.HOURS)));
repository().save(order("ORD-9104", "CUS-97", OrderStatus.PAID, NOW));
var recent = repository().findRecentByCustomer(CustomerId.of("CUS-98"), 2);
assertThat(recent).extracting(o -> o.id().value()).containsExactly("ORD-9102", "ORD-9103");
}
// ...more scenarios in the repo
}
The in-memory version moved to src/test: it is now a test double that is guaranteed to behave like production, because it passes the same contract.
🐘 3. The PostgreSQL adapter
Plain SQL with JdbcClient. The mapping between rows and the Order aggregate lives here and only here. No JPA entities leaking into the domain.
/**
* Driven adapter for PostgreSQL. Plain SQL with JdbcClient: no ORM entities leaking into the domain.
* The mapping between rows and the Order aggregate lives here and only here.
*/
@Repository
public class JdbcOrderRepository implements OrderRepositoryPort {
private static final String COLUMNS = "id, customer_id, status, total, currency, updated_at";
private final JdbcClient jdbc;
public JdbcOrderRepository(JdbcClient jdbc) {
this.jdbc = jdbc;
}
@Override
public Optional<Order> findById(OrderId id) {
return jdbc.sql("SELECT " + COLUMNS + " FROM orders WHERE id = :id")
.param("id", id.value())
.query(JdbcOrderRepository::toOrder)
.optional();
}
@Override
public List<Order> findRecentByCustomer(CustomerId customerId, int limit) {
return jdbc.sql("""
SELECT %s FROM orders
WHERE customer_id = :customerId
ORDER BY updated_at DESC
LIMIT :limit
""".formatted(COLUMNS))
.param("customerId", customerId.value())
.param("limit", limit)
.query(JdbcOrderRepository::toOrder)
.list();
}
@Override
public Order save(Order order) {
jdbc.sql("""
INSERT INTO orders (id, customer_id, status, total, currency, updated_at)
VALUES (:id, :customerId, :status, :total, :currency, :updatedAt)
ON CONFLICT (id) DO UPDATE
SET status = EXCLUDED.status, total = EXCLUDED.total, updated_at = EXCLUDED.updated_at
""")
.param("id", order.id().value())
.param("customerId", order.customerId().value())
.param("status", order.status().name())
.param("total", order.total())
.param("currency", order.currency())
.param("updatedAt", Timestamp.from(order.updatedAt()))
.update();
return order;
}
private static Order toOrder(ResultSet rs, int rowNum) throws SQLException {
return new Order(
OrderId.of(rs.getString("id")),
CustomerId.of(rs.getString("customer_id")),
OrderStatus.valueOf(rs.getString("status")),
rs.getBigDecimal("total"),
rs.getString("currency"),
rs.getTimestamp("updated_at").toInstant());
}
}
The ON CONFLICT ... DO UPDATE upsert makes save work for both new and existing orders.
📜 4. Flyway migrations
-- V1__create_orders.sql
CREATE TABLE orders
(
id VARCHAR(20) PRIMARY KEY,
customer_id VARCHAR(20) NOT NULL,
status VARCHAR(20) NOT NULL CHECK (status IN ('CREATED', 'PAID', 'SHIPPED', 'DELIVERED', 'CANCELLED')),
total NUMERIC(12, 2) NOT NULL CHECK (total >= 0),
currency CHAR(3) NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
-- Supports listRecentOrders: WHERE customer_id = ? ORDER BY updated_at DESC LIMIT ?
CREATE INDEX idx_orders_customer_recent ON orders (customer_id, updated_at DESC);
-- V2__seed_demo_orders.sql
-- Demo data: ORD-1000 .. ORD-1059 for customers CUS-1 .. CUS-6
INSERT INTO orders (id, customer_id, status, total, currency, updated_at)
SELECT 'ORD-' || n,
'CUS-' || (n % 6 + 1),
(ARRAY ['CREATED', 'PAID', 'SHIPPED', 'DELIVERED', 'CANCELLED'])[n % 5 + 1],
15 + (n % 17) * 7.5,
'USD',
now() - ((1060 - n) || ' hours')::interval
FROM generate_series(1000, 1059) AS n;
The index matches the query of listRecentOrders exactly: filter by customer, order by date, take N.
🐳 5. Testcontainers: the real thing in tests
/** A real PostgreSQL in Docker for integration tests. Spring Boot wires the DataSource automatically. */
@TestConfiguration(proxyBeanMethods = false)
public class PostgresTestcontainersConfig {
@Bean
@ServiceConnection
PostgreSQLContainer postgres() {
return new PostgreSQLContainer("postgres:17-alpine");
}
}
/** Same contract as the in-memory fake, now against real PostgreSQL + Flyway migrations. */
@SpringBootTest
@Import(PostgresTestcontainersConfig.class)
class JdbcOrderRepositoryIT extends OrderRepositoryContract {
@Autowired
JdbcOrderRepository repository;
@Override
protected OrderRepositoryPort repository() {
return repository;
}
@Test
void flywaySeedsTheDemoData() {
assertThat(repository.findById(OrderId.of("ORD-1042"))).isPresent();
}
}
@ServiceConnection wires the DataSource to the container automatically. Flyway runs the same migrations as production, so the test also checks the schema.
🚀 6. Local dev with one command
spring-boot-docker-compose (a developmentOnly dependency) reads compose.yaml, starts the containers and creates the connection details for PostgreSQL. No URLs or passwords in application.yml.
🛡️ 7. Guarding the new boundary
One more ArchUnit rule: SQL is only allowed in the JDBC adapter.
@ArchTest
static final ArchRule onlyTheJdbcAdapterKnowsSql = noClasses()
.that().resideOutsideOfPackage("..adapter.out.jdbc..")
.should().dependOnClassesThat().resideInAnyPackage("org.springframework.jdbc..", "java.sql..");
💡 Lessons learned
- A port without a contract test is only a hope. The contract is what makes the swap safe.
- Keep fakes honest. If the fake passes the same contract, unit tests stay fast and trustworthy.
- Plain SQL is fine. For a read-heavy MCP server,
JdbcClientkeeps the adapter small and the queries obvious. - Model the index after the tool. The tool's query defines the access pattern.
🚀 Next in the series
Part 4: observing AI agents in production: Grafana dashboards, SLOs and k6 load tests with real OAuth tokens.
⭐ Code: github.com/cbuelvasc/orders-mcp
👋 I'm Carmelo Buelvas, a Senior Backend Engineer writing about Java, Go and AI agents in production. Let's connect on LinkedIn.
About the author
- Senior Software Engineer with 14+ years building backend systems in Java (8 to 25) and Spring Boot, now also working with Go. I write about MCP, AI agents, cloud-native architecture, observability and taking systems to production. Based in Colombia.
Comments
Join the discussionNo comments yet. Be the first to comment.
MCP in Production: Java & Go
Building Model Context Protocol servers that are ready for production, step by step: hexagonal architecture, Spring AI and Go, security with OAuth 2.1, persistence, observability and load testing. Every post comes with working code on GitHub.