Spring BootOracle DB Integration
Spring Boot

Oracle DB Integration

Oracle Database is the dominant enterprise relational database in large organisations. Spring Boot integrates with Oracle through the Oracle JDBC driver, Hibernate's Oracle dialect, and Spring Data JPA. Oracle's specific features — sequences, ROWNUM, CLOB/BLOB types, stored procedures, and hints — require Oracle-specific configuration and native queries.

Dependencies and Setup

The Oracle JDBC driver is not in Maven Central — it is available from Oracle's Maven repository or must be installed locally. Spring Boot auto-configures Hibernate with the Oracle dialect from the datasource URL.
XML
<!-- pom.xml — Oracle JDBC driver (from Oracle Maven repository): -->
<!-- Add to pom.xml repositories section: -->
<repository>
    <id>oracle</id>
    <url>https://maven.oracle.com</url>
</repository>

<dependency>
    <groupId>com.oracle.database.jdbc</groupId>
    <artifactId>ojdbc11</artifactId>
    <version>23.3.0.23.09</version>
    <scope>runtime</scope>
</dependency>

<!-- Oracle UCP (Universal Connection Pool) — replaces HikariCP for Oracle: -->
<dependency>
    <groupId>com.oracle.database.jdbc</groupId>
    <artifactId>ucp11</artifactId>
    <version>23.3.0.23.09</version>
</dependency>

<!-- Or use HikariCP (default Spring Boot pool) with ojdbc11: -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>

# ── application.yml: ──────────────────────────────────────────────────
spring:
  datasource:
    url: jdbc:oracle:thin:@//localhost:1521/XEPDB1
    # Or using TNS: jdbc:oracle:thin:@mydb_tns
    # Or using wallet (Cloud): jdbc:oracle:thin:@mydb_high?TNS_ADMIN=/wallet
    username: appuser
    password: ${DB_PASSWORD}
    driver-class-name: oracle.jdbc.OracleDriver
    hikari:
      maximum-pool-size: 10
      minimum-idle: 5
      connection-timeout: 20000
      max-lifetime: 1800000
      connection-test-query: SELECT 1 FROM DUAL   # Oracle keepalive

  jpa:
    hibernate:
      ddl-auto: validate
    open-in-view: false
    properties:
      hibernate:
        dialect: org.hibernate.dialect.OracleDialect
        format_sql: true
        jdbc:
          batch_size: 25

  flyway:
    enabled: true
    locations: classpath:db/migration/oracle

Oracle-Specific Entity Mappings

Oracle uses SEQUENCES (never IDENTITY by default), NUMBER for decimals and booleans, CLOB/BLOB for large objects, and DATE for timestamps. JPA entities need Oracle-specific column definitions and ID generation strategies.
Java
@Entity
@Table(name = "PRODUCTS",       // Oracle defaults to UPPERCASE table names
       schema = "MYSCHEMA")     // Oracle schema (equivalent to MySQL database)
public class Product {

    // Oracle SEQUENCE — preferred over IDENTITY in Oracle:
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE,
                    generator = "products_seq")
    @SequenceGenerator(
        name = "products_seq",
        sequenceName = "PRODUCTS_SEQ",   // must CREATE SEQUENCE in migration
        allocationSize = 20              // fetch 20 IDs at once
    )
    private Long id;

    // Oracle VARCHAR2 — max 4000 chars (use CLOB for longer):
    @Column(name = "PRODUCT_NAME", nullable = false, length = 200)
    private String name;

    // Oracle CLOB for long text:
    @Lob
    @Column(name = "DESCRIPTION", columnDefinition = "CLOB")
    private String description;

    // Oracle NUMBER(10,2) for BigDecimal:
    @Column(name = "PRICE", nullable = false,
            precision = 10, scale = 2)
    private BigDecimal price;

    // Oracle has no BOOLEAN — use NUMBER(1): ─────────────────────────
    @Column(name = "ACTIVE", nullable = false)
    @Convert(converter = BooleanToNumberConverter.class)
    private boolean active = true;

    // Oracle DATE for timestamps (stores up to seconds):
    @Column(name = "CREATED_AT", nullable = false, updatable = false)
    private LocalDateTime createdAt;

    // Oracle TIMESTAMP for microseconds:
    @Column(name = "UPDATED_AT", columnDefinition = "TIMESTAMP")
    private LocalDateTime updatedAt;

    // Oracle BLOB for binary data:
    @Lob
    @Column(name = "THUMBNAIL", columnDefinition = "BLOB")
    private byte[] thumbnail;
}

// ── Boolean converter — Oracle NUMBER(1) → boolean: ──────────────────
@Converter
public class BooleanToNumberConverter
        implements AttributeConverter<Boolean, Integer> {

    @Override
    public Integer convertToDatabaseColumn(Boolean value) {
        return value != null && value ? 1 : 0;
    }

    @Override
    public Boolean convertToEntityAttribute(Integer value) {
        return value != null && value == 1;
    }
}

Oracle-Specific Native Queries and Procedures

Oracle-specific SQL — ROWNUM pagination (pre-12c), MERGE, CONNECT BY hierarchical queries, and stored procedures — require native queries or @Procedure mappings.
Java
@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {

    // ── Oracle MERGE (UPSERT): ────────────────────────────────────────
    @Modifying
    @Transactional
    @Query(value = """
            MERGE INTO products p
            USING (SELECT :id AS id, :name AS name, :price AS price FROM DUAL) src
            ON (p.id = src.id)
            WHEN MATCHED THEN
                UPDATE SET p.name = src.name, p.price = src.price,
                           p.updated_at = SYSDATE
            WHEN NOT MATCHED THEN
                INSERT (id, name, price, created_at)
                VALUES (src.id, src.name, src.price, SYSDATE)
            """,
           nativeQuery = true)
    void mergeProduct(@Param("id") Long id,
                      @Param("name") String name,
                      @Param("price") BigDecimal price);

    // ── Oracle CONNECT BY — hierarchical query: ───────────────────────
    @Query(value = """
            SELECT id, name, parent_id, LEVEL AS depth
            FROM categories
            START WITH parent_id IS NULL
            CONNECT BY PRIOR id = parent_id
            ORDER SIBLINGS BY name
            """,
           nativeQuery = true)
    List<Object[]> findCategoryHierarchy();

    // ── Oracle hints for query plan control: ──────────────────────────
    @Query(value = """
            SELECT /*+ INDEX(p IDX_PRODUCTS_CATEGORY) */
                   p.*
            FROM products p
            WHERE p.category = :category
              AND p.active = 1
            ORDER BY p.price ASC
            """,
           nativeQuery = true)
    List<Product> findByCategoryWithHint(@Param("category") String category);

    // ── Oracle analytic functions: ────────────────────────────────────
    @Query(value = """
            SELECT p.*,
                   RANK() OVER (PARTITION BY p.category ORDER BY p.price) AS price_rank,
                   SUM(p.price) OVER (PARTITION BY p.category) AS category_total
            FROM products p
            WHERE p.active = 1
            """,
           nativeQuery = true)
    List<Object[]> findWithAnalytics();
}

// ── Stored procedure with @Procedure: ────────────────────────────────
@Repository
public interface UserRepository extends JpaRepository<User, Long> {

    // Calls Oracle stored procedure: RECALCULATE_STATS(p_user_id IN NUMBER)
    @Procedure(procedureName = "RECALCULATE_STATS")
    void recalculateStats(Long userId);

    // Procedure with OUT parameter:
    @Procedure(procedureName = "GET_USER_DISCOUNT")
    BigDecimal getUserDiscount(Long userId);
}

// ── Using EntityManager for stored procedures directly: ───────────────
@Service
@RequiredArgsConstructor
public class OracleService {

    private final EntityManager em;

    @Transactional
    public String callProcedure(Long userId) {
        StoredProcedureQuery query = em
            .createStoredProcedureQuery("GET_USER_INFO")
            .registerStoredProcedureParameter("p_user_id", Long.class, ParameterMode.IN)
            .registerStoredProcedureParameter("p_result", String.class, ParameterMode.OUT)
            .setParameter("p_user_id", userId);

        query.execute();
        return (String) query.getOutputParameterValue("p_result");
    }
}

Flyway Migrations for Oracle

Flyway supports Oracle with Oracle-specific DDL. A separate migration directory for Oracle allows the same codebase to target multiple databases with appropriate DDL for each.
yaml
# ── application.yml — Oracle-specific Flyway location: ───────────────
spring:
  flyway:
    locations: classpath:db/migration/oracle
    schemas: MYSCHEMA
    default-schema: MYSCHEMA

# ── Migration file layout: ────────────────────────────────────────────
# src/main/resources/db/migration/oracle/
# ├── V1__create_sequences.sql
# ├── V2__create_products_table.sql
# ├── V3__create_indexes.sql
# └── V4__create_procedures.sql

-- V1__create_sequences.sql:
CREATE SEQUENCE PRODUCTS_SEQ
    START WITH 1
    INCREMENT BY 20
    NOCACHE
    NOCYCLE;

CREATE SEQUENCE ORDERS_SEQ
    START WITH 1
    INCREMENT BY 50
    NOCACHE
    NOCYCLE;

-- V2__create_products_table.sql:
CREATE TABLE PRODUCTS (
    ID              NUMBER(19)      NOT NULL,
    PRODUCT_NAME    VARCHAR2(200)   NOT NULL,
    DESCRIPTION     CLOB,
    PRICE           NUMBER(10,2)    NOT NULL,
    CATEGORY        VARCHAR2(100),
    ACTIVE          NUMBER(1)       DEFAULT 1 NOT NULL,
    CREATED_AT      DATE            DEFAULT SYSDATE NOT NULL,
    UPDATED_AT      TIMESTAMP,
    CONSTRAINT PK_PRODUCTS PRIMARY KEY (ID),
    CONSTRAINT CK_PRODUCTS_ACTIVE CHECK (ACTIVE IN (0, 1))
);

-- V3__create_indexes.sql:
CREATE INDEX IDX_PRODUCTS_CATEGORY ON PRODUCTS (CATEGORY);
CREATE INDEX IDX_PRODUCTS_ACTIVE_PRICE ON PRODUCTS (ACTIVE, PRICE);

-- V4__create_procedures.sql:
CREATE OR REPLACE PROCEDURE RECALCULATE_STATS (
    p_user_id IN NUMBER
) AS
BEGIN
    UPDATE user_stats
    SET order_count = (SELECT COUNT(*) FROM orders WHERE user_id = p_user_id),
        updated_at  = SYSDATE
    WHERE user_id = p_user_id;
    COMMIT;
END RECALCULATE_STATS;
/

Docker and Testing

Oracle provides an official Docker image for development. Testcontainers supports Oracle for integration tests, though startup time is significantly longer than PostgreSQL or MySQL.
yaml
# ── docker-compose.yml — Oracle XE for local development: ────────────
services:
  oracle:
    image: container-registry.oracle.com/database/express:21.3.0-xe
    container_name: myapp-oracle
    environment:
      ORACLE_PWD: rootpassword
      ORACLE_CHARACTERSET: AL32UTF8
    ports:
      - "1521:1521"
      - "5500:5500"    # Oracle Enterprise Manager Express
    volumes:
      - oracle_data:/opt/oracle/oradata
    # NOTE: Oracle XE Docker image requires login to container-registry.oracle.com
    # docker login container-registry.oracle.com

volumes:
  oracle_data:

# ── application-dev.yml: ──────────────────────────────────────────────
spring:
  datasource:
    url: jdbc:oracle:thin:@//localhost:1521/XEPDB1
    username: appuser
    password: apppassword

# ── Testcontainers — Oracle (slow startup ~2-3 minutes): ──────────────
<!-- pom.xml: -->
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>oracle-xe</artifactId>
    <scope>test</scope>
</dependency>

@SpringBootTest
@Testcontainers
class ProductRepositoryOracleTest {

    @Container
    static OracleContainer oracle =
        new OracleContainer("gvenzl/oracle-xe:21-slim-faststart")
            .withUsername("testuser")
            .withPassword("testpassword");

    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", oracle::getJdbcUrl);
        registry.add("spring.datasource.username", oracle::getUsername);
        registry.add("spring.datasource.password", oracle::getPassword);
        registry.add("spring.datasource.driver-class-name",
            () -> "oracle.jdbc.OracleDriver");
    }

    @Autowired
    private ProductRepository productRepository;

    @Test
    void mergeCreatesAndUpdatesProduct() {
        productRepository.mergeProduct(1L, "Widget", new BigDecimal("9.99"));
        productRepository.mergeProduct(1L, "Widget Pro", new BigDecimal("19.99"));

        Optional<Product> found = productRepository.findById(1L);
        assertThat(found).isPresent();
        assertThat(found.get().getName()).isEqualTo("Widget Pro");
    }
}