# 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](https://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](https://mermaid.ink/img/pako:eNqNkdFqwjAUQH_lEl-UKbPpxCKzsAmTDceqezR7aJMbW5Y2kqZIEf99se0Gjo3tPuTlHMLh3iPhWiCZAZFKH3gaGwurDSvATVklOxPvU7hHqQ1uGYnO2GMVHXs-ZeSt9c6z8Bx2IKEeLJzsIIxGIURe34EXI9BscK_LzGpTR9pYJwwa43nbZ-SxGOWYO3SbmOswj_eMDLrfsRDfcu6kRfNZ419m0F8y6F8Z0fLcwSoR-MK93Asg0qXdGXxdr5qqJ5HwhcqwsHAFD6o-xPUPle2uYD4P286u3dYK3ZJAZkrNeoJLjtNhaY1-x1mPUj6Z4IVJ_2tGy87EsaQSv8wxDW74lAyB5GjyOBPuxkdiU8ybawuUcaUsOZ0-AC08nkU?type=png&bgColor=white&width=1600)

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

```java
/** 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:

```java
/**
 * 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](https://mermaid.ink/img/pako:eNpd0EtLAzEQAOC_MsSLQkutRa1FerCCrFisdW-mh2wysaF5yCSLlNL_7nRRDzuHgTnMN4-D0MmgmIGwPn3rraIC9YOMwLGoP6SQrZlOkLOxCK9kkNb4lbIrifaLFAspXe4bGs3PVZO7Cgrmki-k2PwxMBzOoVqyVsUlBu7sQTV3dIhsr27UGILz3mXUKZrcd1ZPp7WeTaN7SFX_Eryw4azHUyBUHlYpl0_C97cXcBEek94h_au57D2ebMszZ2cW7UTfDviStEMur-_wshEDEAEpKGf4UwdRthi6nxm0qvVFHI8_HUhtqA?type=png&bgColor=white&width=1600)

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.

```java
/**
 * 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

```sql
-- 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);
```

```sql
-- 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

```java
/** 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");
    }
}
```

```java
/** 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](https://mermaid.ink/img/pako:eNplkMFqwzAMhl9F-LTBvDYbjG6MwkhGD9shZLBL3YNjK2lIHAXFJozSd59XBmszHXSQPunXr4MwZFE8gag6msxes4f3QvUQI3v93Cqhgl3d25jNw6MKd8ul_auUJdwuata2wwlKIl-EXokdSLmGLI3D48BNX8uflrRkWmRpyA00YsR-VdITnm-2V-diyQoi5mvGUYnrS_YtPb_LJgm0-GU60u2_rcWFAxOXMjn0ewzjnN3MWA3RWaV7PQdf8jySxBZ5lM4MzyUv1pn2-oMCGwQdPMmpYbRxUtyAcMhONzZ--SCitDv922KlQ-fF8fgNsP96TA?type=png&bgColor=white&width=1600)

`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.

```java
@ArchTest
static final ArchRule onlyTheJdbcAdapterKnowsSql = noClasses()
        .that().resideOutsideOfPackage("..adapter.out.jdbc..")
        .should().dependOnClassesThat().resideInAnyPackage("org.springframework.jdbc..", "java.sql..");
```

---

## 💡 Lessons learned

![Lessons learned](https://mermaid.ink/img/pako:eNpVj7EKg0AMhl8lZFZK61JKEaqOHYp063WIdxEFvStnjg7iu_ewWGiGfwjfl_DPqJ1hPAG2g3vrjrzAtVYW4lweClUwx4xjmpbh5rxMQJ7Pjd_l2lnxpGVS-IQ0zaH445ngzpOAdAwbmqyidd8tGXoJ-00vN93E1FkGNdMAVbE6vQWJ136_qhU-NIc9lM4zBCsu6I5NBDABHNmP1JtYbMb4a1wrGm4pDILL8gEXAFA-?type=png&bgColor=white&width=1600)

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](https://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](https://www.linkedin.com/in/cbuelvasc/).

