Skip to main content

Swapping Adapters: PostgreSQL Without Touching the Core

Updated 6 min readMarkdown
Part of seriesMCP in Production: Java & Go

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

Swapping the in-memory adapter for PostgreSQL

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
}

One contract test, two adapters

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

Local dev stack started by bootRun

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

Lessons learned

  1. A port without a contract test is only a hope. The contract is what makes the swap safe.
  2. Keep fakes honest. If the fake passes the same contract, unit tests stay fast and trustworthy.
  3. Plain SQL is fine. For a read-heavy MCP server, JdbcClient keeps the adapter small and the queries obvious.
  4. 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.

No comments yet. Be the first to comment.

MCP in Production: Java & Go

Part 3 of 6

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.

Up nextObserving AI Agents: Grafana Dashboards and k6 Load Tests for MCPTL;DR Agents don't behave like humans: they call tools in bursts, in loops and in chains. If you can't see latency, errors and auth failures per tool, you can't run an MCP server in production. ⭐ Co