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

  1. Define resources and endpoints first using OpenAPI (Swagger) or GraphQL schema.
  2. Design REST endpoints with predictable patterns:
    • GET /api/v1/users
    • POST /api/v1/auth/login
    • GET /api/v1/items/{id}
    • POST /api/v1/items
  3. Use consistent naming, versioning (v1), and pagination standards (limit, offset or cursor).
  4. Model request/response DTOs in Java and provide example responses in the OpenAPI spec.
  5. 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'
}
Java

User 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
}
Java

UserDTO

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) 
Java

UserRepository

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);
}
Java

UserService

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);
    }
}
Java

UserController

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 */);
    }
}
Java

JWTService (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);
    }
}
Java

WebSecurityConfig

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();
    }
}
Java

OpenAPI 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 }
YAML

Designing 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
}
Swift
Keychaing 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 }
    }
}
Swift

Now 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)
    }
}
Swift

Finally 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")
    }
}
Swift

Create 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

  1. Create Login.storyboard with:
    • UITextField for email (tag 1).
    • UITextField for password (tag 2).
    • UIButton for login (tag 3).
    • UIActivityIndicatorView for loading.
  2. 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
  1. 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) }
    }
}
Swift

Use 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

Leave a Reply

Verified by MonsterInsights