Spring BootSQL Server Integration
Spring Boot

SQL Server Integration

Microsoft SQL Server is a widely used enterprise relational database, especially in .NET and Java shops running on Windows or Azure. Spring Boot integrates with SQL Server through the Microsoft JDBC driver (mssql-jdbc), Hibernate's SQL Server dialect, and Spring Data JPA. This entry covers setup, SQL Server-specific entity mappings, T-SQL native queries, stored procedures, and testing with Testcontainers.

Dependencies and Setup

Microsoft publishes the SQL Server JDBC driver to Maven Central. Spring Boot auto-configures HikariCP, Hibernate, and the SQL Server dialect from the datasource URL.
XML
<!-- pom.xml: -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>

<dependency>
    <groupId>com.microsoft.sqlserver</groupId>
    <artifactId>mssql-jdbc</artifactId>
    <scope>runtime</scope>
    <!-- Version managed by Spring Boot BOM -->
</dependency>

# ── application.yml — minimum configuration: ──────────────────────────
spring:
  datasource:
    url: jdbc:sqlserver://localhost:1433;databaseName=mydb;encrypt=false
    username: sa
    password: ${DB_PASSWORD}
    driver-class-name: com.microsoft.sqlserver.jdbc.SQLServerDriver
    hikari:
      maximum-pool-size: 10
      minimum-idle: 5
      connection-timeout: 20000
      idle-timeout: 300000
      max-lifetime: 1200000
      keepalive-time: 60000
      pool-name: myapp-mssql-pool
      connection-test-query: SELECT 1

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

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

# ── Production connection URL options: ────────────────────────────────
# Encrypt in transit (required for Azure SQL):
# jdbc:sqlserver://myserver.database.windows.net:1433;
#   databaseName=mydb;encrypt=true;trustServerCertificate=false;
#   hostNameInCertificate=*.database.windows.net;loginTimeout=30

# Windows auth (no username/password):
# jdbc:sqlserver://localhost:1433;databaseName=mydb;integratedSecurity=true

# Azure SQL with Managed Identity:
# jdbc:sqlserver://myserver.database.windows.net:1433;
#   databaseName=mydb;authentication=ActiveDirectoryManagedIdentity

SQL Server-Specific Entity Mappings

SQL Server has specific data types and behaviours that affect JPA entity mappings — IDENTITY columns, uniqueidentifier for UUIDs, nvarchar for Unicode strings, datetime2 for timestamps, and bit for booleans.
Java
@Entity
@Table(name = "products",
       schema = "dbo",           // SQL Server schema (default is dbo)
       indexes = {
           @Index(name = "idx_products_category", columnList = "category"),
           @Index(name = "idx_products_sku", columnList = "sku",
                  unique = true)
       })
public class Product {

    // SQL Server IDENTITY — maps to GenerationType.IDENTITY:
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    // Or use uniqueidentifier (UUID):
    // @Id
    // @GeneratedValue(strategy = GenerationType.UUID)
    // @Column(name = "id", columnDefinition = "uniqueidentifier",
    //         updatable = false)
    // private UUID id;

    // SQL Server NVARCHAR — Unicode strings (preferred over VARCHAR):
    @Column(name = "product_name", nullable = false,
            columnDefinition = "NVARCHAR(200)")
    private String name;

    // SQL Server NVARCHAR(MAX) for long text (replaces CLOB):
    @Column(name = "description",
            columnDefinition = "NVARCHAR(MAX)")
    private String description;

    // SQL Server DECIMAL for money:
    @Column(nullable = false, precision = 10, scale = 2)
    private BigDecimal price;

    // SQL Server BIT for boolean (maps automatically):
    @Column(nullable = false)
    private boolean active = true;

    // SQL Server DATETIME2 for high-precision timestamps:
    @Column(name = "created_at", nullable = false, updatable = false,
            columnDefinition = "DATETIME2")
    private LocalDateTime createdAt;

    @Column(name = "updated_at",
            columnDefinition = "DATETIME2")
    private LocalDateTime updatedAt;

    // SQL Server IMAGE/VARBINARY(MAX) for binary data:
    @Lob
    @Column(name = "thumbnail",
            columnDefinition = "VARBINARY(MAX)")
    private byte[] thumbnail;

    // SQL Server MONEY type — use DECIMAL in JPA, cast in SQL:
    @Column(name = "list_price", precision = 19, scale = 4)
    private BigDecimal listPrice;

    @PrePersist
    void onCreate() { createdAt = updatedAt = LocalDateTime.now(); }

    @PreUpdate
    void onUpdate() { updatedAt = LocalDateTime.now(); }
}

SQL Server-Specific Native Queries

T-SQL features — MERGE, OFFSET/FETCH pagination, window functions, FOR JSON, and table hints — require native queries. SQL Server's MERGE statement is particularly powerful for upsert operations.
Java
@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {

    // ── SQL Server MERGE (UPSERT): ────────────────────────────────────
    @Modifying
    @Transactional
    @Query(value = """
            MERGE products AS target
            USING (VALUES (:sku, :name, :price)) AS source (sku, name, price)
            ON target.sku = source.sku
            WHEN MATCHED THEN
                UPDATE SET target.name = source.name,
                           target.price = source.price,
                           target.updated_at = GETDATE()
            WHEN NOT MATCHED THEN
                INSERT (sku, name, price, created_at)
                VALUES (source.sku, source.name, source.price, GETDATE());
            """,
           nativeQuery = true)
    void mergeProduct(@Param("sku") String sku,
                      @Param("name") String name,
                      @Param("price") BigDecimal price);

    // ── SQL Server window functions: ──────────────────────────────────
    @Query(value = """
            SELECT *,
                   ROW_NUMBER() OVER (PARTITION BY category ORDER BY price ASC)
                       AS row_num,
                   AVG(CAST(price AS FLOAT)) OVER (PARTITION BY category)
                       AS category_avg
            FROM products
            WHERE active = 1
            """,
           nativeQuery = true)
    List<Object[]> findWithWindowFunctions();

    // ── SQL Server FOR JSON (return JSON from SQL): ───────────────────
    @Query(value = """
            SELECT id, product_name AS name, price, category
            FROM products
            WHERE active = 1
            FOR JSON PATH, ROOT('products')
            """,
           nativeQuery = true)
    String findAllAsJson();

    // ── SQL Server table hints — lock control: ────────────────────────
    @Query(value = """
            SELECT * FROM products WITH (NOLOCK)
            WHERE category = :category
            """,
           nativeQuery = true)
    List<Product> findByCategoryNoLock(@Param("category") String category);

    // ── SQL Server TOP — limit results: ───────────────────────────────
    @Query(value = "SELECT TOP 10 * FROM products ORDER BY created_at DESC",
           nativeQuery = true)
    List<Product> findTop10Recent();

    // ── SQL Server TRY_CAST — safe type conversion: ───────────────────
    @Query(value = """
            SELECT * FROM products
            WHERE TRY_CAST(attributes AS NVARCHAR(MAX)) LIKE '%:keyword%'
            """,
           nativeQuery = true)
    List<Product> searchAttributes(@Param("keyword") String keyword);
}

Stored Procedures and Functions

SQL Server stored procedures and user-defined functions are common in enterprise environments. Spring Data JPA provides @Procedure for simple procedures and the EntityManager StoredProcedureQuery API for full control.
Java
// ── @Procedure — simple stored procedure call: ───────────────────────
@Repository
public interface OrderRepository extends JpaRepository<Order, Long> {

    // Calls: EXEC CalculateOrderTotal @orderId = ?, @discount = ?
    @Procedure(procedureName = "CalculateOrderTotal")
    BigDecimal calculateTotal(Long orderId, BigDecimal discount);

    // Procedure with no return value:
    @Procedure(procedureName = "ArchiveOldOrders")
    void archiveOrders(LocalDateTime cutoff);
}

// ── EntityManager — full procedure control with multiple parameters: ───
@Service
@RequiredArgsConstructor
@Transactional
public class SqlServerService {

    private final EntityManager em;

    // Call procedure with IN and OUT parameters:
    public OrderResult processOrder(Long orderId) {
        StoredProcedureQuery query = em
            .createStoredProcedureQuery("ProcessOrder")
            .registerStoredProcedureParameter(
                "p_order_id", Long.class, ParameterMode.IN)
            .registerStoredProcedureParameter(
                "p_status", String.class, ParameterMode.OUT)
            .registerStoredProcedureParameter(
                "p_total", BigDecimal.class, ParameterMode.OUT)
            .setParameter("p_order_id", orderId);

        query.execute();
        String status = (String) query.getOutputParameterValue("p_status");
        BigDecimal total = (BigDecimal) query.getOutputParameterValue("p_total");
        return new OrderResult(orderId, status, total);
    }

    // Call a table-valued function:
    public List<Object[]> getProductsByPriceRange(
            BigDecimal min, BigDecimal max) {
        return em.createNativeQuery("""
            SELECT * FROM dbo.GetProductsByPriceRange(:min, :max)
            ORDER BY price ASC
            """)
            .setParameter("min", min)
            .setParameter("max", max)
            .getResultList();
    }
}

// ── Corresponding SQL Server DDL: ─────────────────────────────────────
-- V3__create_procedures.sql:

CREATE PROCEDURE ProcessOrder
    @p_order_id BIGINT,
    @p_status NVARCHAR(50) OUTPUT,
    @p_total DECIMAL(10,2) OUTPUT
AS
BEGIN
    SET NOCOUNT ON;
    SELECT @p_total = SUM(quantity * unit_price)
    FROM order_items WHERE order_id = @p_order_id;
    SET @p_status = 'PROCESSED';
    UPDATE orders SET status = @p_status, total = @p_total
    WHERE id = @p_order_id;
END;
GO

CREATE FUNCTION dbo.GetProductsByPriceRange
    (@min_price DECIMAL(10,2), @max_price DECIMAL(10,2))
RETURNS TABLE AS
RETURN (
    SELECT * FROM products
    WHERE price BETWEEN @min_price AND @max_price AND active = 1
);
GO

Flyway Migrations for SQL Server

Flyway supports SQL Server with T-SQL syntax. Separate migration directories per database allow the same Java codebase to target multiple database vendors.
yaml
# ── application.yml — SQL Server Flyway location: ─────────────────────
spring:
  flyway:
    enabled: true
    locations: classpath:db/migration/sqlserver
    schemas: dbo
    default-schema: dbo
    table: flyway_schema_history    # Flyway tracking table

# ── Migration file layout: ────────────────────────────────────────────
# src/main/resources/db/migration/sqlserver/
# ├── V1__create_products_table.sql
# ├── V2__create_orders_table.sql
# ├── V3__create_procedures.sql
# └── V4__create_indexes.sql

-- V1__create_products_table.sql:
CREATE TABLE dbo.products (
    id              BIGINT          NOT NULL IDENTITY(1,1),
    product_name    NVARCHAR(200)   NOT NULL,
    description     NVARCHAR(MAX),
    price           DECIMAL(10,2)   NOT NULL,
    category        NVARCHAR(100),
    sku             NVARCHAR(50),
    active          BIT             NOT NULL DEFAULT 1,
    created_at      DATETIME2       NOT NULL DEFAULT GETDATE(),
    updated_at      DATETIME2,
    CONSTRAINT PK_products PRIMARY KEY (id),
    CONSTRAINT UQ_products_sku UNIQUE (sku),
    CONSTRAINT CK_products_price CHECK (price >= 0)
);
GO

-- V4__create_indexes.sql:
CREATE NONCLUSTERED INDEX idx_products_category
    ON dbo.products (category)
    INCLUDE (product_name, price, active);

CREATE NONCLUSTERED INDEX idx_products_active_price
    ON dbo.products (active, price)
    WHERE active = 1;   -- filtered index
GO

Docker and Testcontainers

Microsoft publishes official SQL Server Docker images. Testcontainers provides MSSQLServerContainer for integration tests with a real SQL Server instance.
yaml
# ── docker-compose.yml — SQL Server for local development: ───────────
services:
  sqlserver:
    image: mcr.microsoft.com/mssql/server:2022-latest
    container_name: myapp-sqlserver
    environment:
      ACCEPT_EULA: Y
      SA_PASSWORD: YourStrong@Password1
      MSSQL_PID: Developer           # free Developer edition
    ports:
      - "1433:1433"
    volumes:
      - sqlserver_data:/var/opt/mssql

volumes:
  sqlserver_data:

# ── application-dev.yml: ──────────────────────────────────────────────
spring:
  datasource:
    url: jdbc:sqlserver://localhost:1433;databaseName=mydb;encrypt=false
    username: sa
    password: YourStrong@Password1
  jpa:
    hibernate:
      ddl-auto: create-drop

# ── Testcontainers: ───────────────────────────────────────────────────
<!-- pom.xml: -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-testcontainers</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>mssqlserver</artifactId>
    <scope>test</scope>
</dependency>

@SpringBootTest
@Testcontainers
class ProductRepositoryTest {

    @Container
    static MSSQLServerContainer<?> sqlserver =
        new MSSQLServerContainer<>("mcr.microsoft.com/mssql/server:2022-latest")
            .acceptLicense()
            .withPassword("YourStrong@Password1");

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

    @Autowired
    private ProductRepository productRepository;

    @Test
    void mergeCreatesAndUpdatesProduct() {
        productRepository.mergeProduct("SKU-001", "Widget", new BigDecimal("9.99"));
        productRepository.mergeProduct("SKU-001", "Widget Pro", new BigDecimal("19.99"));

        Optional<Product> found = productRepository.findBySku("SKU-001");
        assertThat(found).isPresent();
        assertThat(found.get().getName()).isEqualTo("Widget Pro");
    }
}