Skip to main content

API Contracts

Contract-First Development

Define API contracts using OpenAPI specifications before implementation. This ensures explicit, versioned contracts that serve as the source of truth for both frontend and backend teams.

Overview

API contracts define the data exchanged between clients and servers. These contracts specify request/response schemas, validation rules, and data types. Explicitly defining contracts prevents integration issues, enables code generation, and provides clear documentation.

This guide covers:

  • Contract-first vs code-first approaches
  • OpenAPI specification for contracts
  • Request/response model design
  • Validation strategies
  • Code generation from contracts

Core Principles

  • Contract as Truth: OpenAPI spec is the authoritative contract
  • Explicit Validation: Define validation rules in the contract
  • Schema Consistency: Requests and responses follow defined schemas
  • Immutable DTOs: Use immutable data transfer objects
  • Code Generation: Generate models from contracts, not vice versa

Contract-First vs. Code-First

Contract-first (recommended): Define OpenAPI specification first, then generate code from it. This ensures the API contract is explicit, versioned, and serves as the source of truth. Changes to the spec are reviewed before implementation.

Code-first: Write code with annotations, generate OpenAPI spec from code. This is faster initially but makes the API contract implicit - it's whatever the code happens to produce. Accidental changes to code can break the API contract.

For comprehensive details on contract-first development, see OpenAPI Specifications.

Why Contract-First?

  1. Shared understanding: Frontend and backend teams align on contracts before implementation
  2. Earlier feedback: API design is reviewed before any code is written
  3. Independent development: Frontend can work against mocks while backend is being built
  4. Prevents drift: Code generation fails if implementation doesn't match contract
  5. Documentation first: API documentation exists from day one

OpenAPI Contract Definition

OpenAPI specifications describe your API's endpoints, request/response schemas, validation rules, and error responses in a standardized YAML or JSON format.

Complete Request/Response Example

# openapi.yml
openapi: 3.0.3
info:
title: Payment Service API
version: 1.0.0

paths:
/api/v1/payments:
post:
summary: Create payment
operationId: createPayment # Used to generate method names
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreatePaymentRequest'
responses:
'201':
description: Payment created
content:
application/json:
schema:
$ref: '#/components/schemas/PaymentDto'
'400':
description: Invalid request
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'

components:
schemas:
CreatePaymentRequest:
type: object
required: # Required fields enforced in validation
- amount
- currency
- userId
- accountId
properties:
amount:
type: number
format: double
minimum: 0.01 # Validation rule embedded in spec
example: 100.00 # Examples aid understanding and testing
currency:
type: string
pattern: '^[A-Z]{3}$' # Regex validation for currency codes
example: USD
userId:
type: string
example: USER-123
accountId:
type: string
example: ACC-456
description:
type: string
maxLength: 500
example: Invoice payment

PaymentDto:
type: object
properties:
id:
type: string
example: PAY-789
amount:
type: number
format: double
example: 100.00
currency:
type: string
example: USD
status:
type: string
enum: [PENDING, COMPLETED, FAILED, CANCELLED] # Constrained values
example: PENDING
createdAt:
type: string
format: date-time # ISO 8601 timestamp
example: 2025-01-28T10:15:30Z

ErrorResponse:
type: object
required:
- timestamp
- status
- error
- message
- path
properties:
timestamp:
type: string
format: date-time
status:
type: integer
error:
type: string
message:
type: string
path:
type: string
validationErrors:
type: object
additionalProperties:
type: string

Benefits of OpenAPI contracts:

  • Shared understanding: Frontend and backend teams align on data structures
  • Code generation: Generate server stubs, client SDKs, and DTOs automatically
  • Validation: Validate requests/responses against specs in tests
  • Documentation: Generate interactive API documentation (Swagger UI, Redoc)
  • Mocking: Generate mock servers for frontend development before backend is ready

Code Generation from Contracts

OpenAPI Generator can generate server-side interfaces, client SDKs, and model classes from your specification. This eliminates manual DTO creation and ensures code matches the spec.

Backend Code Generation

// build.gradle
plugins {
id 'org.openapi.generator' version '7.10.0'
}

openApiGenerate {
generatorName = 'spring'
inputSpec = "$projectDir/src/main/resources/openapi.yml"
outputDir = "$buildDir/generated"
apiPackage = 'com.example.payment.api'
modelPackage = 'com.example.payment.api.model'
configOptions = [
interfaceOnly : 'true', // Generate interfaces, not implementations
useSpringBoot3 : 'true',
useTags : 'true'
]
}

// Add generated sources to compilation
sourceSets.main.java.srcDir "$buildDir/generated/src/main/java"
compileJava.dependsOn tasks.openApiGenerate

Generated artifacts:

  • API interfaces: Controller interfaces with Spring annotations
  • Model classes: DTOs with validation annotations
  • Documentation: Javadoc from OpenAPI descriptions

For frontend integration, see API Integration (Frontend) for generating TypeScript clients.

Implementing Generated Interfaces

After generation, implement the generated interfaces in your controllers. The interface defines the contract; your implementation provides the business logic.

// Generated by OpenAPI Generator (don't modify this file manually)
public interface PaymentsApi {
ResponseEntity<PaymentDto> createPayment(CreatePaymentRequest request);
}

// Your implementation
@RestController
public class PaymentController implements PaymentsApi {

private final PaymentService paymentService;

@Override
public ResponseEntity<PaymentDto> createPayment(CreatePaymentRequest request) {
// Implementation delegates to service layer
Payment payment = paymentService.createPayment(request);
URI location = ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(payment.getId())
.toUri();
return ResponseEntity.created(location).body(PaymentDto.from(payment));
}
}

Why use generated interfaces? If you change the OpenAPI spec (e.g., add a required parameter), regeneration will cause compilation errors in your implementation, forcing you to update your code to match the new contract. This prevents accidental contract violations.

For backend-specific implementation details, see API Integration (Backend).


Request Validation

Input validation is the first line of defense against malformed data, injection attacks, and unexpected behavior. Validating requests early - before they reach business logic - prevents invalid data from corrupting your system and provides clear feedback to clients.

Validation happens at multiple levels:

  1. Schema validation: Ensuring request structure matches expected format (JSON syntax, required fields)
  2. Field validation: Checking individual field constraints (type, length, format)
  3. Business validation: Enforcing domain-specific rules (e.g., sufficient balance for transfer)

Bean Validation

Bean Validation (Jakarta Validation) provides declarative validation annotations that execute automatically when @Valid is applied to request parameters. This separates validation logic from business logic, making code cleaner and validation rules reusable.

import jakarta.validation.Valid;
import jakarta.validation.constraints.*;

public record CreatePaymentRequest(

@NotNull(message = "Amount is required")
@Positive(message = "Amount must be positive")
BigDecimal amount,

@NotBlank(message = "Currency is required")
@Pattern(regexp = "^[A-Z]{3}$", message = "Currency must be 3-letter code")
String currency,

@NotBlank(message = "User ID is required")
String userId,

@NotBlank(message = "Account ID is required")
String accountId,

@Size(max = 500, message = "Description cannot exceed 500 characters")
String description
) {}

@RestController
@RequestMapping("/api/v1/payments")
public class PaymentController {

@PostMapping
public ResponseEntity<PaymentDto> createPayment(
@Valid @RequestBody CreatePaymentRequest request) {
// Validation happens automatically BEFORE this method is called
// If validation fails, MethodArgumentNotValidException is thrown
// and handled by @RestControllerAdvice (see API Patterns - Error Handling)
Payment payment = paymentService.createPayment(request);
return ResponseEntity.created(location).body(PaymentDto.from(payment));
}
}

Key annotations:

  • @NotNull: Field must not be null (but can be empty string for strings)
  • @NotBlank: String must not be null, empty, or only whitespace
  • @NotEmpty: Collection/array must not be null or empty
  • @Positive / @PositiveOrZero: Numeric value must be positive
  • @Min / @Max: Numeric value bounds
  • @Size: String length or collection size constraints
  • @Pattern: Regex validation for strings
  • @Email: Email format validation

Why use records? Java records provide immutable DTOs with minimal boilerplate. Validation annotations on record components apply automatically, and immutability prevents accidental modification after validation.

Custom Validation

For domain-specific validation that standard annotations can't express, create custom validators. Custom validators encapsulate complex validation logic and can access external dependencies (e.g., database lookups).

import jakarta.validation.Constraint;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;

// 1. Define custom annotation
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = ValidCurrencyValidator.class)
public @interface ValidCurrency {
String message() default "Invalid currency code";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}

// 2. Implement validator logic
public class ValidCurrencyValidator implements ConstraintValidator<ValidCurrency, String> {

private static final Set<String> SUPPORTED_CURRENCIES = Set.of("USD", "EUR", "GBP");

@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
// Null values are considered valid; use @NotNull for null checks
if (value == null) {
return true;
}
return SUPPORTED_CURRENCIES.contains(value);
}
}

// 3. Use custom annotation
public record CreatePaymentRequest(
@ValidCurrency // Custom domain-specific validation
String currency
) {}

When to use custom validators:

  • Domain-specific rules (e.g., valid currency code, valid account number format)
  • Cross-field validation (e.g., endDate must be after startDate)
  • Validation requiring external lookups (e.g., user exists in database)
  • Complex regex or business logic

Important: Validators should be stateless and thread-safe. If accessing dependencies (e.g., repositories), inject them via Spring's dependency injection.

Validation vs. Business Logic

Distinguish between validation (structural correctness) and business logic (domain rules):

  • Validation: Is the amount positive? Is the email format valid? Is the field within length limits?
  • Business logic: Does the user have sufficient balance? Is the account active? Is the transaction limit exceeded?

Validation fails with 400 Bad Request (client provided invalid input). Business logic failures return 422 Unprocessable Entity (input is valid, but business rules prevent processing). For comprehensive security validation patterns, see Input Validation.


Response Models

Response models (DTOs) transfer data from server to client. Design response models carefully to balance completeness with security and performance.

Response Design Principles

  1. Never expose entities: Don't return JPA entities or domain objects directly
  2. Mask sensitive data: Hide or mask sensitive fields (passwords, full account numbers)
  3. Include metadata: Add timestamps, pagination info, and hypermedia links where appropriate
  4. Consistent structure: All responses follow the same patterns
  5. Version-aware: Include fields that support versioning if needed

Example Response DTO

public record PaymentDto(
String id,
BigDecimal amount,
String currency,
PaymentStatus status,
Instant createdAt,
Instant updatedAt,
String userId
) {
// Factory method to convert from domain entity
public static PaymentDto from(Payment payment) {
return new PaymentDto(
payment.getId(),
payment.getAmount(),
payment.getCurrency(),
payment.getStatus(),
payment.getCreatedAt(),
payment.getUpdatedAt(),
payment.getUserId()
);
}
}

// BAD: Exposing entity directly
@GetMapping("/{id}")
public Payment getPayment(@PathVariable String id) {
return paymentRepository.findById(id); // Exposes internal structure, lazy-loading issues
}

// GOOD: Using DTO
@GetMapping("/{id}")
public ResponseEntity<PaymentDto> getPayment(@PathVariable String id) {
Payment payment = paymentService.getPayment(id);
return ResponseEntity.ok(PaymentDto.from(payment));
}

Masking Sensitive Data

// BAD: Expose sensitive data
public record AccountDto(
String id,
String accountNumber, // Full account number exposed
String routingNumber, // Sensitive
BigDecimal balance
) {}

// GOOD: Mask sensitive data
public record AccountDto(
String id,
String maskedAccountNumber, // "***1234"
BigDecimal balance
) {
public static AccountDto from(Account account) {
return new AccountDto(
account.getId(),
maskAccountNumber(account.getAccountNumber()),
account.getBalance()
);
}

private static String maskAccountNumber(String accountNumber) {
return "***" + accountNumber.substring(accountNumber.length() - 4);
}
}

Further Reading

Internal Documentation

External Resources


Summary

Key Takeaways:

  1. Contract-first - Define OpenAPI spec before implementation
  2. Code generation - Generate DTOs and interfaces from contracts
  3. Validation - Use Bean Validation for structural correctness
  4. Immutable DTOs - Use Java records for request/response models
  5. Separate concerns - Validation (400) vs business logic (422)
  6. Mask sensitive data - Never expose raw sensitive information
  7. Never expose entities - Always use DTOs for API responses

Next Steps: Review API Patterns for pagination, versioning, and error handling strategies.