This post explains, step by step, how to design, implement, secure, deploy, and maintain a modern iOS App with a Separate Java Spring Boot Backend. It covers architecture, API design , provides some code samples for the backend, authentication mechanism in both Java Spring Boot and iOS Swift. The post concludes with next steps in what could potentially be explored next.
Architecture overview – iOS App with a Separate Java Spring Boot Backend
- Client tier: iOS app written in Swift using SwiftUI or UIKit; offline caching, local persistence with Core Data or SQLite, background sync, push notifications.
- API tier: Java Spring Boot REST API or GraphQL endpoint; layered architecture with Controller → Service → Repository.
- Data tier: Relational database (PostgreSQL, MySQL) or NoSQL (MongoDB) depending on access patterns.
Benefits of separating the iOS app from a Java Spring Boot backend: clear separation of concerns, independent releases, language/technology specialization, reusability of backend for other clients (web, Android), and stronger security boundaries.
Planning and API contract
- Define resources and endpoints first using OpenAPI (Swagger) or GraphQL schema.
- Design REST endpoints with predictable patterns:
- GET /api/v1/users
- POST /api/v1/auth/login
- GET /api/v1/items/{id}
- POST /api/v1/items
- Use consistent naming, versioning (v1), and pagination standards (limit, offset or cursor).
- Model request/response DTOs in Java and provide example responses in the OpenAPI spec.
- Add error codes and structured error responses with helpful fields: code, message, details, timestamp.
Benefits of an API-first approach: easier front-end integration, automated client generation, better contract testing, and smoother onboarding for mobile developers.
Building the Spring Boot backend
Project setup and structure
- Use Spring Boot starter dependencies: spring-boot-starter-web, spring-boot-starter-data-jpa, spring-boot-starter-security, spring-boot-starter-actuator.
- Layered package layout:
- com.mydaytodo.app.controller
- com.mydaytodo.app.service
- com.mydaytodo.app.repository
- com.mydaytodo.app.dto
- com.mydaytodo.app.config
- com.mydaytodo.app.model
- Use Maven or Gradle. Enable Java 17 or later for long-term support and performance improvements.
Persistence and data modeling
- Choose PostgreSQL for strong relational capabilities and JSONB support.
- Use JPA/Hibernate with clear entity models and DTOs for API boundary.
- Implement migrations with Flyway or Liquibase for repeatable schema changes.
Security and authentication
- Use JWT access tokens for stateless authentication or OAuth2 / OpenID Connect for federated identity and SSO.
- Implement refresh token patterns for long-lived sessions.
- Protect endpoints with role-based access control annotations (@PreAuthorize).
- Defend against common attacks: CSRF where applicable, input validation, rate limiting.
API performance and resilience
- Add caching with Redis or in-memory caches for read-heavy endpoints.
- Use Spring Cache annotations or RedisTemplate for distributed cache.
- Implement retries and circuit breaker patterns with Resilience4j for downstream stability.
- Add pagination and sparse fieldsets to reduce payload sizes.
Observability and monitoring
- Expose Actuator endpoints and secure them.
- Send logs to a centralized system (ELK stack or cloud logging).
- Expose metrics to Prometheus and create Grafana dashboards for request latency, error rates, and DB throughput.
Packaging and deployment
- Containerize with Docker, build small images using distroless or lightweight base images.
- Push images to a registry and deploy to Kubernetes, ECS, or PaaS.
- Use health checks and readiness/liveness probes.
Some code samples
I have been using Gradle a lot lately at my day job, so for this post I will be using Gradle and not Maven as a build tool.
build.gradle,
plugins {
id 'org.springframework.boot' version '3.2.0'
id 'io.spring.dependency-management' version '1.1.0'
id 'java'
}
group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '17'
repositories { mavenCentral() }
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'com.auth0:java-jwt:4.5.0'
runtimeOnly 'org.postgresql:postgresql'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
JavaUser Entity
package com.mydaytodo.app.model;
import jakarta.persistence.*;
@Entity
@Table(name = "users")
public class User {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
private String email;
@Column(nullable = false)
private String passwordHash;
private String fullName;
// getters/setters
}JavaUserDTO
package com.mydaytodo.app.dto;
public record UserResponse(Long id, String email, String fullName) {}
public record RegisterRequest(String email, String password, String fullName) {}
public record LoginRequest(String email, String password) {}
public record AuthResponse(String accessToken, String refreshToken)
JavaUserRepository
package com.mydaytodo.app.repository;
import com.mydaytodo.app.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
}JavaUserService
package com.mydaytodo.app.service;
import com.mydaytodo.app.dto.*;
import com.mydaytodo.app.model.User;
import com.mydaytodo.app.repository.UserRepository;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
@Service
public class UserService {
private final UserRepository users;
private final PasswordEncoder encoder;
private final JwtService jwtService;
public UserService(UserRepository users, PasswordEncoder encoder, JwtService jwtService) {
this.users = users; this.encoder = encoder; this.jwtService = jwtService;
}
public UserResponse register(RegisterRequest req) {
var user = new User();
user.setEmail(req.email());
user.setFullName(req.fullName());
user.setPasswordHash(encoder.encode(req.password()));
var saved = users.save(user);
return new UserResponse(saved.getId(), saved.getEmail(), saved.getFullName());
}
public AuthResponse login(LoginRequest req) {
var user = users.findByEmail(req.email())
.orElseThrow(() -> new RuntimeException("Invalid credentials"));
if (!encoder.matches(req.password(), user.getPasswordHash())) {
throw new RuntimeException("Invalid credentials");
}
var access = jwtService.createAccessToken(user);
var refresh = jwtService.createRefreshToken(user);
return new AuthResponse(access, refresh);
}
}
JavaUserController
package com.mydaytodo.app.controller;
import com.mydaytodo.app.dto.*;
import com.mydaytodo.app.service.UserService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/v1/auth")
public class UserController {
private final UserService svc;
public UserController(UserService svc) { this.svc = svc; }
@PostMapping("/register")
public ResponseEntity<UserResponse> register(@RequestBody RegisterRequest req) {
return ResponseEntity.ok(svc.register(req));
}
@PostMapping("/login")
public ResponseEntity<AuthResponse> login(@RequestBody LoginRequest req) {
return ResponseEntity.ok(svc.login(req));
}
@GetMapping("/me")
public ResponseEntity<UserResponse> me(@RequestAttribute("userId") Long userId) {
// implementation omitted: load user and map to DTO
return ResponseEntity.ok(/* UserResponse */);
}
}
JavaJWTService (close to what I have used in my other DocumentSharing app repo)
package com.mydaytodo.app.service;
import com.mydaytodo.app.model.User;
import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import org.springframework.stereotype.Service;
import java.util.Date;
@Service
public class JwtService {
private final Algorithm alg = Algorithm.HMAC256("replace-with-secure-secret");
public String createAccessToken(User user) {
return JWT.create()
.withSubject(user.getId().toString())
.withClaim("email", user.getEmail())
.withExpiresAt(new Date(System.currentTimeMillis() + 15 * 60 * 1000))
.sign(alg);
}
public String createRefreshToken(User user) {
return JWT.create()
.withSubject(user.getId().toString())
.withExpiresAt(new Date(System.currentTimeMillis() + 7L * 24 * 3600 * 1000))
.sign(alg);
}
}
JavaWebSecurityConfig
package com.mydaytodo.app.config;
import org.springframework.context.annotation.*;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class WebSecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf().disable()
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v1/auth/**", "/actuator/health").permitAll()
.anyRequest().authenticated())
.addFilterBefore(new JwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
JavaOpenAPI swagger.yaml file
openapi: 3.0.3
info:
title: My Day Todo - API
version: "1.0.0"
paths:
/api/v1/auth/register:
post:
summary: Register user
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/RegisterRequest'
responses:
'200':
description: user registered
content:
application/json:
schema:
$ref: '#/components/schemas/UserResponse'
/api/v1/auth/login:
post:
summary: Login
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/LoginRequest'
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/AuthResponse'
components:
schemas:
RegisterRequest:
type: object
properties:
email: { type: string }
password: { type: string }
fullName: { type: string }
required: [email, password]
LoginRequest:
type: object
properties:
email: { type: string }
password: { type: string }
AuthResponse:
type: object
properties:
accessToken: { type: string }
refreshToken: { type: string }
UserResponse:
type: object
properties:
id: { type: integer }
email: { type: string }
fullName: { type: string }
YAMLDesigning the iOS app (Swift)
For this one, first let’s start by looking at the Swift source code for the models, as well as network requests and sessions.
import Foundation
struct UserResponse: Codable {
let id: Int
let email: String
let fullName: String?
}
struct RegisterRequest: Codable {
let email: String
let password: String
let fullName: String?
}
struct LoginRequest: Codable {
let email: String
let password: String
}
struct AuthResponse: Codable {
let accessToken: String
let refreshToken: String
}
SwiftKeychaing storage
This is to store the secure token to be exchanged between the backend and the iOS app front-end.
import Foundation
import Security
enum KeychainError: Error { case saveFailed, readFailed, deleteFailed }
final class KeychainStorage {
static let shared = KeychainStorage()
private init() {}
func save(_ value: String, key: String) throws {
guard let data = value.data(using: .utf8) else { throw KeychainError.saveFailed }
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecValueData as String: data
]
SecItemDelete(query as CFDictionary)
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else { throw KeychainError.saveFailed }
}
func read(_ key: String) throws -> String {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess,
let data = result as? Data,
let str = String(data: data, encoding: .utf8) else { throw KeychainError.readFailed }
return str
}
func delete(_ key: String) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key
]
let status = SecItemDelete(query as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else { throw KeychainError.deleteFailed }
}
}
SwiftNow let’s look at some network layer code, and we won’t be using any libraries here and just classes from the Foundation library.
import Foundation
protocol NetworkSession {
func data(for request: URLRequest) async throws -> (Data, URLResponse)
}
extension URLSession: NetworkSession {}
final class ApiClient {
private let baseURL: URL
private let session: NetworkSession
init(baseURL: URL, session: NetworkSession = URLSession.shared) {
self.baseURL = baseURL
self.session = session
}
private func request(path: String, method: String = "GET", body: Data? = nil, requiresAuth: Bool = false) -> URLRequest {
var req = URLRequest(url: baseURL.appendingPathComponent(path))
req.httpMethod = method
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
if let body = body { req.httpBody = body }
if requiresAuth, let token = try? KeychainStorage.shared.read("accessToken") {
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
return req
}
func post<T: Codable, U: Codable>(_ path: String, body: T) async throws -> U {
let data = try JSONEncoder().encode(body)
let req = request(path: path, method: "POST", body: data)
let (respData, resp) = try await session.data(for: req)
guard let http = resp as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
throw URLError(.badServerResponse)
}
return try JSONDecoder().decode(U.self, from: respData)
}
func get<U: Codable>(_ path: String) async throws -> U {
let req = request(path: path, method: "GET", body: nil, requiresAuth: true)
let (respData, resp) = try await session.data(for: req)
guard let http = resp as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
throw URLError(.badServerResponse)
}
return try JSONDecoder().decode(U.self, from: respData)
}
}
SwiftFinally the AuthRepository to manage all that we have saved,
import Foundation
final class AuthRepository {
private let client: ApiClient
init(client: ApiClient) { self.client = client }
func register(email: String, password: String, fullName: String?) async throws -> UserResponse {
let req = RegisterRequest(email: email, password: password, fullName: fullName)
return try await client.post("/api/v1/auth/register", body: req)
}
func login(email: String, password: String) async throws -> UserResponse {
let req = LoginRequest(email: email, password: password)
let auth = try await client.post("/api/v1/auth/login", body: req) as AuthResponse
try KeychainStorage.shared.save(auth.accessToken, key: "accessToken")
try KeychainStorage.shared.save(auth.refreshToken, key: "refreshToken")
// Optionally decode access token to return user or call /me
return try await client.get("/api/v1/auth/me")
}
func logout() throws {
try KeychainStorage.shared.delete("accessToken")
try KeychainStorage.shared.delete("refreshToken")
}
}
SwiftCreate a NetworkService responsible for request building, retries, logging, and centralized error handling.
This is a good point to stop sharing anymore information in this post, so I will just mention a few next steps.
Next steps
Plan the next 2–4 sprints around three parallel tracks: finish the API contract and mocks, implement core backend endpoints and auth, and build the iOS UI flows (auth, list/detail, create/edit). Stabilize a minimal end-to-end path (register → login → list → detail) so you can run CI smoke tests and iterate on UX based on real data. Keep the OpenAPI/Swagger contract up to date and publish a Postman collection or mock server for mobile devs to work against while backend work continues.
UI build checklist
- Define screens and flows: Auth (Register, Login, Forgot), Home/List, Detail, Create/Edit, Settings, Profile.
- Design components: Buttons, inputs, cells, toasts/alerts, loading and empty states, error states.
- State management: Choose MVVM, Redux-like pattern, or Combine + ObservableObject for SwiftUI; use coordinator/router for navigation.
- Networking integration: Wire ApiClient and AuthRepository, implement token refresh and retry logic, add correlation-id header for tracing.
- Secure storage: Keychain for access/refresh tokens, biometric-protected secrets when needed.
- Offline & persistence: Decide Core Data vs Realm; implement optimistic updates and BackgroundTasks for sync.
- Accessibility and localization: Dynamic Type, VoiceOver labels, right-to-left support, localized strings.
- Theming and assets: Color tokens, SF Symbols, scalable images, asset catalogs for dark mode.
- Testing: Unit tests for ViewModels, UI tests for critical flows, snapshot tests for visual regressions.
- CI/CD: TestFlight pipeline, automated UI runs on simulators, release notes templating.
Add telemetry hooks (error events, API latency) during screen development so you get early feedback in staging.
How to build the UI
Now, I am not too famaliar with using SwitUI, and not because I have anything against it, but I have nothad the opportunity to work with it. As I mentioned in one of my earlier posts, for my day job, I work with Java backend and Reactjs front-end. Since, I do know how to build UI using Storyboard, let’s have a look at a few samples of the storyboard app.
Storyboard / UIKit examples
LoginViewController with URLSession integration
- Create Login.storyboard with:
- UITextField for email (tag 1).
- UITextField for password (tag 2).
- UIButton for login (tag 3).
- UIActivityIndicatorView for loading.
- Wire outlets and actions, then implement:
swift
import UIKit
class LoginViewController: UIViewController {
@IBOutlet weak var emailField: UITextField!
@IBOutlet weak var passwordField: UITextField!
@IBOutlet weak var loginButton: UIButton!
@IBOutlet weak var spinner: UIActivityIndicatorView!
var authRepo: AuthRepository! // injected by AppDelegate/SceneDelegate or container
override func viewDidLoad() {
super.viewDidLoad()
spinner.isHidden = true
}
@IBAction func loginTapped(_ sender: Any) {
guard let email = emailField.text, let pass = passwordField.text, !email.isEmpty else {
presentAlert("Email required")
return
}
spinner.isHidden = false
spinner.startAnimating()
loginButton.isEnabled = false
Task {
do {
_ = try await authRepo.login(email: email, password: pass)
DispatchQueue.main.async {
self.spinner.stopAnimating()
self.navigateToHome()
}
} catch {
DispatchQueue.main.async {
self.spinner.stopAnimating()
self.loginButton.isEnabled = true
self.presentAlert("Login error: \(error.localizedDescription)")
}
}
}
}
func presentAlert(_ message: String) {
let a = UIAlertController(title: "Error", message: message, preferredStyle: .alert)
a.addAction(UIAlertAction(title: "OK", style: .default))
present(a, animated: true)
}
func navigateToHome() {
// perform segue or replace root view controller
}
}
Swift- Injection: instantiate LoginViewController from storyboard and set authRepo before presentation, keep storyboards minimal and favor composition for testability.
TableView list and cell reuse
class ItemsViewController: UITableViewController {
var viewModel: ItemsListViewModel!
override func viewDidLoad() {
super.viewDidLoad()
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
Task { await loadInitial() }
}
func loadInitial() async {
do { try await viewModel.refresh(); tableView.reloadData() } catch { showError(error) }
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
viewModel.items.count
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let item = viewModel.items[indexPath.row]
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
cell.textLabel?.text = item.title
cell.detailTextLabel?.text = item.subtitle
return cell
}
override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
Task { await viewModel.loadMoreIfNeeded(currentIndex: indexPath.row) }
}
}
SwiftUse reuse identifiers, prefetching APIs, and background image decoders to keep scrolling smooth.
Integration tips and checklist while building UI
- Wire screens to ApiClient early: stub network calls with MockSession to develop UI without backend instability.
- Implement token refresh in one place: centralize refresh logic inside ApiClient or an interceptor so Storyboard and SwiftUI flows share the same behavior.
- Add loading and error UX: consistent error banners and retry patterns improve user trust.
- Accessibility: set accessibilityLabel and accessibilityValue for all interactive controls.
- Dark mode and dynamic type: test UI with larger text sizes and dark scheme.
- Localization: extract strings into Localizable.strings from day one.
- Analytics: add lightweight events for sign-in success/failure and list page load times.
- End-to-end test plan: automated UI tests for sign-up/login, item create/edit/delete, token expiry and refresh flow.
Keep the backend contract authoritative; update OpenAPI whenever you add fields so autogenerated clients and mocks stay current.
Final pre-release checklist
- UI: all screens implemented, accessibility checks passed, localization placeholders created.
- Security: TLS enforced, Keychain used, secrets not in code, certificate pinning decision documented.
- Performance: list pagination validated, images lazy-loaded and cached.
- Testing: unit tests, UI tests, contract tests (Pact/OpenAPI validations) passing in CI.
- Observability: mobile telemetry and backend metrics enabled, correlation-id flows tested.
- Release readiness: privacy fields filled, App Store metadata and screenshots ready, staged rollout plan defined.
Begin by shipping the minimal end-to-end path to a small TestFlight cohort, monitor errors and telemetry, iterate quickly, and then expand release scope.
Conclusion and next steps
Build your API first with a clear OpenAPI contract, secure the backend with JWT or OAuth2, and implement a robust, testable networking layer in the iOS app that uses Keychain for tokens and async/await or Combine for concurrency. Containerize and CI/CD your Spring Boot service, instrument observability across both platforms, and adopt offline-first patterns for a reliable mobile experience. Follow the patterns and checklist in this guide to ship a scalable, maintainable iOS app backed by a Java Spring Boot API.
While you are here, maybe try one of my apps for the iPhone.
Snap! I was there on the App Store
Listed below are the links to the first two parts of this series
How to build a blog engine with React & Spring Boot – Part 1 – My Day To-Do (mydaytodo.com)
How to build a blog engine with React & Spring Boot – Part 2 – My Day To-Do (mydaytodo.com)
Here are some of my other bloposts,
How to unit test react-redux app – My Day To-Do (mydaytodo.com)
0 Comments