Back to Journal
SaaS Engineering

Complete Guide to SaaS API Design with Java

A comprehensive guide to implementing SaaS API Design using Java, covering architecture, code examples, and production-ready patterns.

Muneer Puthiya Purayil 16 min read

Java remains one of the most popular choices for building SaaS APIs, and for good reason. Its mature ecosystem, powerful type system, and battle-tested frameworks like Spring Boot provide everything needed to build APIs that scale from startup to enterprise. This guide covers the complete architecture of a production-grade SaaS API in Java, from project setup through multi-tenancy, security, and deployment.

Every pattern here reflects production-tested approaches used in SaaS platforms serving millions of requests daily. The focus is on modern Java (21+) with Spring Boot 3, leveraging records, virtual threads, and the latest features that make Java APIs both performant and maintainable.

Project Structure

A clean Spring Boot project follows domain-driven boundaries:

1src/main/java/com/example/api/
2├── ApiApplication.java
3├── config/
4│ ├── SecurityConfig.java
5│ ├── WebConfig.java
6│ ├── OpenApiConfig.java
7│ └── CacheConfig.java
8├── domain/
9│ ├── order/
10│ │ ├── Order.java
11│ │ ├── OrderController.java
12│ │ ├── OrderService.java
13│ │ ├── OrderRepository.java
14│ │ └── dto/
15│ │ ├── CreateOrderRequest.java
16│ │ └── OrderResponse.java
17│ └── user/
18│ ├── User.java
19│ ├── UserController.java
20│ ├── UserService.java
21│ └── UserRepository.java
22├── infrastructure/
23│ ├── exception/
24│ │ ├── GlobalExceptionHandler.java
25│ │ ├── ResourceNotFoundException.java
26│ │ └── BusinessRuleException.java
27│ ├── security/
28│ │ ├── JwtAuthenticationFilter.java
29│ │ ├── JwtTokenProvider.java
30│ │ └── TenantContext.java
31│ └── persistence/
32│ └── TenantInterceptor.java
33└── shared/
34 ├── ApiResponse.java
35 ├── PagedResponse.java
36 └── ProblemDetail.java
37 

Entity and DTO Design with Records

Modern Java records make DTOs concise and immutable:

java
1// Domain entity
2@Entity
3@Table(name = "orders")
4public class Order {
5 @Id
6 @GeneratedValue(strategy = GenerationType.UUID)
7 private String id;
8 
9 @Column(name = "tenant_id", nullable = false)
10 private String tenantId;
11 
12 @Column(name = "customer_id", nullable = false)
13 private String customerId;
14 
15 @Enumerated(EnumType.STRING)
16 private OrderStatus status;
17 
18 @Column(name = "total_amount", precision = 10, scale = 2)
19 private BigDecimal totalAmount;
20 
21 @Column(length = 3)
22 private String currency;
23 
24 @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
25 private List<OrderItem> items = new ArrayList<>();
26 
27 @CreationTimestamp
28 private Instant createdAt;
29 
30 @UpdateTimestamp
31 private Instant updatedAt;
32 
33 // Getters, setters omitted for brevity
34}
35 
36// Request DTO using Java record
37public record CreateOrderRequest(
38 @NotNull @Pattern(regexp = "^[0-9a-f\\-]{36}$")
39 String customerId,
40 
41 @NotEmpty @Valid
42 List<OrderItemRequest> items,
43 
44 @NotNull @Size(min = 3, max = 3)
45 String currency,
46 
47 Map<String, Object> metadata
48) {}
49 
50public record OrderItemRequest(
51 @NotBlank String productId,
52 @Min(1) int quantity,
53 @NotNull @DecimalMin("0.01") BigDecimal unitPrice
54) {}
55 
56// Response DTO
57public record OrderResponse(
58 String id,
59 String customerId,
60 String status,
61 BigDecimal totalAmount,
62 String currency,
63 List<OrderItemResponse> items,
64 Instant createdAt
65) {
66 public static OrderResponse from(Order order) {
67 return new OrderResponse(
68 order.getId(),
69 order.getCustomerId(),
70 order.getStatus().name(),
71 order.getTotalAmount(),
72 order.getCurrency(),
73 order.getItems().stream()
74 .map(OrderItemResponse::from)
75 .toList(),
76 order.getCreatedAt()
77 );
78 }
79}
80 

Controller Layer

Controllers handle HTTP routing and input validation:

java
1@RestController
2@RequestMapping("/api/v1/orders")
3@RequiredArgsConstructor
4public class OrderController {
5 
6 private final OrderService orderService;
7 
8 @PostMapping
9 public ResponseEntity<ApiResponse<OrderResponse>> createOrder(
10 @Valid @RequestBody CreateOrderRequest request) {
11 
12 String tenantId = TenantContext.getCurrentTenantId();
13 Order order = orderService.createOrder(tenantId, request);
14 
15 return ResponseEntity
16 .status(HttpStatus.CREATED)
17 .body(ApiResponse.success(OrderResponse.from(order)));
18 }
19 
20 @GetMapping("/{id}")
21 public ResponseEntity<ApiResponse<OrderResponse>> getOrder(@PathVariable String id) {
22 String tenantId = TenantContext.getCurrentTenantId();
23 Order order = orderService.getOrder(tenantId, id);
24 
25 return ResponseEntity.ok(ApiResponse.success(OrderResponse.from(order)));
26 }
27 
28 @GetMapping
29 public ResponseEntity<PagedResponse<OrderResponse>> listOrders(
30 @RequestParam(defaultValue = "0") int page,
31 @RequestParam(defaultValue = "20") int size,
32 @RequestParam(required = false) String status,
33 @RequestParam(defaultValue = "createdAt") String sortBy,
34 @RequestParam(defaultValue = "desc") String sortDir) {
35 
36 String tenantId = TenantContext.getCurrentTenantId();
37 size = Math.min(size, 100);
38 
39 Sort sort = sortDir.equalsIgnoreCase("asc")
40 ? Sort.by(sortBy).ascending()
41 : Sort.by(sortBy).descending();
42 
43 Page<Order> orders = orderService.listOrders(
44 tenantId, status, PageRequest.of(page, size, sort)
45 );
46 
47 List<OrderResponse> responses = orders.getContent().stream()
48 .map(OrderResponse::from)
49 .toList();
50 
51 return ResponseEntity.ok(PagedResponse.of(responses, orders));
52 }
53 
54 @PatchMapping("/{id}/status")
55 public ResponseEntity<ApiResponse<OrderResponse>> updateStatus(
56 @PathVariable String id,
57 @Valid @RequestBody UpdateOrderStatusRequest request) {
58 
59 String tenantId = TenantContext.getCurrentTenantId();
60 Order order = orderService.updateStatus(tenantId, id, request.status());
61 
62 return ResponseEntity.ok(ApiResponse.success(OrderResponse.from(order)));
63 }
64}
65 

Service Layer with Transaction Management

Services contain business logic and orchestrate repository calls:

java
1@Service
2@RequiredArgsConstructor
3public class OrderService {
4 
5 private final OrderRepository orderRepository;
6 private final CustomerRepository customerRepository;
7 private final EventPublisher eventPublisher;
8 
9 @Transactional
10 public Order createOrder(String tenantId, CreateOrderRequest request) {
11 Customer customer = customerRepository
12 .findByIdAndTenantId(request.customerId(), tenantId)
13 .orElseThrow(() -> new ResourceNotFoundException("Customer", request.customerId()));
14 
15 if (customer.getStatus() != CustomerStatus.ACTIVE) {
16 throw new BusinessRuleException("Cannot create order for inactive customer");
17 }
18 
19 Order order = new Order();
20 order.setTenantId(tenantId);
21 order.setCustomerId(request.customerId());
22 order.setCurrency(request.currency());
23 order.setStatus(OrderStatus.PENDING);
24 
25 BigDecimal total = BigDecimal.ZERO;
26 for (OrderItemRequest itemReq : request.items()) {
27 OrderItem item = new OrderItem();
28 item.setProductId(itemReq.productId());
29 item.setQuantity(itemReq.quantity());
30 item.setUnitPrice(itemReq.unitPrice());
31 item.setSubtotal(itemReq.unitPrice().multiply(BigDecimal.valueOf(itemReq.quantity())));
32 order.addItem(item);
33 total = total.add(item.getSubtotal());
34 }
35 
36 order.setTotalAmount(total);
37 Order saved = orderRepository.save(order);
38 
39 eventPublisher.publish(new OrderCreatedEvent(saved));
40 
41 return saved;
42 }
43 
44 @Transactional(readOnly = true)
45 public Order getOrder(String tenantId, String orderId) {
46 return orderRepository
47 .findByIdAndTenantId(orderId, tenantId)
48 .orElseThrow(() -> new ResourceNotFoundException("Order", orderId));
49 }
50 
51 @Transactional(readOnly = true)
52 public Page<Order> listOrders(String tenantId, String status, Pageable pageable) {
53 if (status != null) {
54 return orderRepository.findByTenantIdAndStatus(
55 tenantId, OrderStatus.valueOf(status.toUpperCase()), pageable
56 );
57 }
58 return orderRepository.findByTenantId(tenantId, pageable);
59 }
60 
61 @Transactional
62 public Order updateStatus(String tenantId, String orderId, OrderStatus newStatus) {
63 Order order = getOrder(tenantId, orderId);
64 
65 if (!order.getStatus().canTransitionTo(newStatus)) {
66 throw new BusinessRuleException(
67 String.format("Cannot transition from %s to %s",
68 order.getStatus(), newStatus)
69 );
70 }
71 
72 order.setStatus(newStatus);
73 return orderRepository.save(order);
74 }
75}
76 

Repository Layer with Spring Data JPA

java
1public interface OrderRepository extends JpaRepository<Order, String> {
2 
3 Optional<Order> findByIdAndTenantId(String id, String tenantId);
4 
5 Page<Order> findByTenantId(String tenantId, Pageable pageable);
6 
7 Page<Order> findByTenantIdAndStatus(String tenantId, OrderStatus status, Pageable pageable);
8 
9 @Query("""
10 SELECT o FROM Order o
11 WHERE o.tenantId = :tenantId
12 AND o.createdAt BETWEEN :startDate AND :endDate
13 ORDER BY o.createdAt DESC
14 """)
15 List<Order> findByTenantIdAndDateRange(
16 @Param("tenantId") String tenantId,
17 @Param("startDate") Instant startDate,
18 @Param("endDate") Instant endDate
19 );
20 
21 @Query("SELECT SUM(o.totalAmount) FROM Order o WHERE o.tenantId = :tenantId AND o.status = 'COMPLETED'")
22 BigDecimal calculateTotalRevenue(@Param("tenantId") String tenantId);
23}
24 

Need a second opinion on your saas engineering architecture?

I run free 30-minute strategy calls for engineering teams tackling this exact problem.

Book a Free Call

Security Configuration

Configure Spring Security with JWT authentication:

java
1@Configuration
2@EnableWebSecurity
3@RequiredArgsConstructor
4public class SecurityConfig {
5 
6 private final JwtAuthenticationFilter jwtFilter;
7 
8 @Bean
9 public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
10 return http
11 .csrf(csrf -> csrf.disable())
12 .sessionManagement(session ->
13 session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
14 .authorizeHttpRequests(auth -> auth
15 .requestMatchers("/health", "/api/v1/auth/**", "/swagger-ui/**", "/v3/api-docs/**").permitAll()
16 .requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
17 .requestMatchers("/api/v1/**").authenticated()
18 .anyRequest().denyAll())
19 .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
20 .build();
21 }
22}
23 
24@Component
25@RequiredArgsConstructor
26public class JwtAuthenticationFilter extends OncePerRequestFilter {
27 
28 private final JwtTokenProvider tokenProvider;
29 
30 @Override
31 protected void doFilterInternal(
32 HttpServletRequest request,
33 HttpServletResponse response,
34 FilterChain chain) throws ServletException, IOException {
35 
36 String token = extractToken(request);
37 
38 if (token != null && tokenProvider.validateToken(token)) {
39 Claims claims = tokenProvider.getClaims(token);
40 String userId = claims.getSubject();
41 String tenantId = claims.get("tenant_id", String.class);
42 List<String> roles = claims.get("roles", List.class);
43 
44 TenantContext.setCurrentTenantId(tenantId);
45 
46 var authorities = roles.stream()
47 .map(role -> new SimpleGrantedAuthority("ROLE_" + role))
48 .toList();
49 
50 var authentication = new UsernamePasswordAuthenticationToken(
51 userId, null, authorities
52 );
53 SecurityContextHolder.getContext().setAuthentication(authentication);
54 }
55 
56 try {
57 chain.doFilter(request, response);
58 } finally {
59 TenantContext.clear();
60 }
61 }
62 
63 private String extractToken(HttpServletRequest request) {
64 String header = request.getHeader("Authorization");
65 if (header != null && header.startsWith("Bearer ")) {
66 return header.substring(7);
67 }
68 return null;
69 }
70}
71 

Global Exception Handler

Centralized error handling with RFC 7807 Problem Details:

java
1@RestControllerAdvice
2public class GlobalExceptionHandler {
3 
4 private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
5 
6 @ExceptionHandler(ResourceNotFoundException.class)
7 public ResponseEntity<ProblemDetail> handleNotFound(ResourceNotFoundException ex) {
8 ProblemDetail problem = ProblemDetail.forStatusAndDetail(
9 HttpStatus.NOT_FOUND, ex.getMessage()
10 );
11 problem.setTitle("Resource Not Found");
12 problem.setType(URI.create("https://api.example.com/errors/not-found"));
13 return ResponseEntity.status(404).body(problem);
14 }
15 
16 @ExceptionHandler(BusinessRuleException.class)
17 public ResponseEntity<ProblemDetail> handleBusinessRule(BusinessRuleException ex) {
18 ProblemDetail problem = ProblemDetail.forStatusAndDetail(
19 HttpStatus.UNPROCESSABLE_ENTITY, ex.getMessage()
20 );
21 problem.setTitle("Business Rule Violation");
22 return ResponseEntity.status(422).body(problem);
23 }
24 
25 @ExceptionHandler(MethodArgumentNotValidException.class)
26 public ResponseEntity<ProblemDetail> handleValidation(MethodArgumentNotValidException ex) {
27 ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.UNPROCESSABLE_ENTITY);
28 problem.setTitle("Validation Failed");
29 
30 List<Map<String, String>> errors = ex.getBindingResult().getFieldErrors().stream()
31 .map(err -> Map.of(
32 "field", err.getField(),
33 "message", err.getDefaultMessage()
34 ))
35 .toList();
36 
37 problem.setProperty("errors", errors);
38 return ResponseEntity.status(422).body(problem);
39 }
40 
41 @ExceptionHandler(Exception.class)
42 public ResponseEntity<ProblemDetail> handleUnexpected(Exception ex) {
43 log.error("Unhandled exception", ex);
44 ProblemDetail problem = ProblemDetail.forStatusAndDetail(
45 HttpStatus.INTERNAL_SERVER_ERROR,
46 "An unexpected error occurred"
47 );
48 problem.setTitle("Internal Server Error");
49 return ResponseEntity.status(500).body(problem);
50 }
51}
52 

Multi-Tenant Context with ThreadLocal

java
1public class TenantContext {
2 private static final ThreadLocal<String> currentTenant = new ThreadLocal<>();
3 
4 public static void setCurrentTenantId(String tenantId) {
5 currentTenant.set(tenantId);
6 }
7 
8 public static String getCurrentTenantId() {
9 String tenantId = currentTenant.get();
10 if (tenantId == null) {
11 throw new IllegalStateException("No tenant context set");
12 }
13 return tenantId;
14 }
15 
16 public static void clear() {
17 currentTenant.remove();
18 }
19}
20 
21// Hibernate filter for automatic tenant scoping
22@FilterDef(name = "tenantFilter", parameters = @ParamDef(name = "tenantId", type = String.class))
23@Filter(name = "tenantFilter", condition = "tenant_id = :tenantId")
24@Entity
25public class Order {
26 // entity fields...
27}
28 
29// Enable filter per request
30@Component
31public class TenantInterceptor implements HandlerInterceptor {
32 
33 @PersistenceContext
34 private EntityManager entityManager;
35 
36 @Override
37 public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
38 String tenantId = TenantContext.getCurrentTenantId();
39 Session session = entityManager.unwrap(Session.class);
40 session.enableFilter("tenantFilter").setParameter("tenantId", tenantId);
41 return true;
42 }
43}
44 

Virtual Threads for High Concurrency

Enable Java 21 virtual threads for dramatically improved concurrent request handling:

yaml
1# application.yml
2spring:
3 threads:
4 virtual:
5 enabled: true
6 datasource:
7 url: ${DATABASE_URL}
8 hikari:
9 maximum-pool-size: 20
10 minimum-idle: 5
11 connection-timeout: 5000
12 

Conclusion

Java with Spring Boot provides one of the most complete frameworks for building SaaS APIs. The combination of strong typing, annotation-driven configuration, and a vast ecosystem of battle-tested libraries means you can focus on business logic rather than infrastructure plumbing.

The patterns covered here—domain-driven structure, record-based DTOs, transactional services, JWT security, Hibernate tenant filtering, and global exception handling—form a cohesive architecture that scales from small teams to large engineering organizations. Java's verbosity is offset by the clarity it provides: every dependency is explicit, every transaction boundary is visible, and every error path is handled.

With Java 21's virtual threads, the historical performance gap between Java and lighter-weight alternatives has narrowed considerably. Combined with Spring Boot 3's native compilation support via GraalVM, Java APIs can now achieve startup times and memory footprints that were previously only possible with Go or Rust.

FAQ

Need expert help?

Building with saas engineering?

I help teams ship production-grade systems. From architecture review to hands-on builds.

Muneer Puthiya Purayil

SaaS Architect & AI Systems Engineer. 10+ years shipping production infrastructure across fintech, automotive, e-commerce, and healthcare.

Engage

Start a
Conversation.

For teams building at scale: SaaS platforms, agentic AI systems, and enterprise mobile infrastructure. Scope and fit are evaluated before any engagement begins.

Limited availability · Q3 / Q4 2026