Architecture of iOS App using Supabase

  • Post category:Blog / ios

Production-Ready Architecture + Many-to-Many Relationships Explained

Modern iOS apps demand clean architecture, scalability, and maintainabilityβ€”especially when working with backend-as-a-service platforms like Supabase. In this guide, we’ll walk through a production-style iOS app structure, demonstrate how to integrate Supabase, and clearly explain how to model and fetch many-to-many (M:M) relationships using a real-world example: Projects ↔ Employees.

Why Supabase for iOS?

Supabase provides the following:

  • PostgreSQL database
  • Auto-generated APIs
  • Real-time capabilities
  • Swift client support

The Core Problem: Many-to-Many Relationship

Scenario:

  • A Project can have multiple Employees
  • An Employee can belong to multiple Projects

Database Design

projects
- id (UUID)
- name

employees
- id (UUID)
- name

project_employees (junction table)
- project_id (FK)
- employee_id (FK)

Production-Ready Folder Structure

πŸ“¦ App
┣ πŸ“‚ Models β†’ Base DB models
┣ πŸ“‚ DTOs β†’ API / Embedded response models
┣ πŸ“‚ Services β†’ Supabase calls (CRUD)
┣ πŸ“‚ ViewModels β†’ UI logic
β”— πŸ“‚ Views β†’ SwiftUI

1. Models (Database Layer)

These represent your raw database tables.

πŸ“‚ Models/Project.swift

import Foundation

struct Project: Codable, Identifiable {
    let id: UUID
    let name: String
}

πŸ“‚ Models/Employee.swift

import Foundation

struct Employee: Codable, Identifiable {
    let id: UUID
    let name: String
}

2. DTOs (API Layer)

Supabase returns nested JSON when fetching relationships. We handle that using DTOs (Data Transfer Objects).

πŸ“‚ DTOs/ProjectWithEmployeesDTO.swift

import Foundation

// Raw response from Supabase
struct ProjectWithEmployeesDTO: Codable {
    let id: UUID
    let name: String
    let project_employees: [ProjectEmployeeWrapper]
}

// Wrapper for junction table
struct ProjectEmployeeWrapper: Codable {
    let employees: Employee
}

Transform DTO β†’ UI Model

extension ProjectWithEmployeesDTO {
    func toProjectDetail() -> ProjectDetail {
        ProjectDetail(
            id: id,
            name: name,
            employees: project_employees.map { $0.employees }
        )
    }
}

3. View Model Struct (UI-Friendly Model)

πŸ“‚ ViewModels/ProjectDetail.swift

import Foundation

struct ProjectDetail: Identifiable {
    let id: UUID
    let name: String
    let employees: [Employee]
}

4. Supabase Manager (Singleton)

Central place to configure Supabase client.

πŸ“‚ Services/SupabaseManager.swift

import Supabase

class SupabaseManager {
    static let shared = SupabaseManager()
    
    let client: SupabaseClient
    
    private init() {
        client = SupabaseClient(
            supabaseURL: URL(string: "https://YOUR_PROJECT.supabase.co")!,
            supabaseKey: "YOUR_ANON_KEY"
        )
    }
}

5. Service Layer (Business Logic + API Calls)

πŸ“‚ Services/ProjectService.swift

import Foundation
import Supabase

class ProjectService {
    private let client = SupabaseManager.shared.client
    
    // MARK: - Fetch simple projects
    func fetchProjects() async throws -> [Project] {
        try await client
            .from("projects")
            .select()
            .execute()
            .value
    }
    
    // MARK: - Fetch projects with employees (M:M)
    func fetchProjectDetails() async throws -> [ProjectDetail] {
        
        let response: [ProjectWithEmployeesDTO] = try await client
            .from("projects")
            .select("""
                id,
                name,
                project_employees (
                    employees (
                        id,
                        name
                    )
                )
            """)
            .execute()
            .value
        
        return response.map { $0.toProjectDetail() }
    }
    
    // MARK: - Create Project
    func createProject(name: String) async throws -> Project {
        let project = Project(id: UUID(), name: name)
        
        return try await client
            .from("projects")
            .insert(project)
            .select()
            .single()
            .execute()
            .value
    }
    
    // MARK: - Assign Employee (Insert into junction table)
    func assignEmployee(projectId: UUID, employeeId: UUID) async throws {
        let relation = [
            "project_id": projectId,
            "employee_id": employeeId
        ]
        
        try await client            
            .from("project_employees")
            .insert(relation)
            .execute()
    }
}

Supabase automatically performs the JOIN and returns nested JSON. Following things happen in the above code:

  • Fetch from projects
  • Join project_employees
  • From that, join employees

6. ViewModel (State + UI Logic)

πŸ“‚ ViewModels/ProjectViewModel.swift

import Foundation
import Combine

@MainActor
@Observable
class ProjectViewModel {    
    var projects: [ProjectDetail] = []
    
    private let service = ProjectService()
    
    func loadProjects() async {
        do {
            projects = try await service.fetchProjectDetails()
        } catch {
            print("Error:", error)
        }
    }
}

7. SwiftUI View

πŸ“‚ Views/ProjectListView.swift

import SwiftUI

struct ProjectListView: View {
    @State private var vm = ProjectViewModel()
    
    var body: some View {
        List(vm.projects) { project in
            Section(header: Text(project.name)) {
                ForEach(project.employees) { employee in
                    Text(employee.name)
                }
            }
        }
        .task {
            await vm.loadProjects()
        }
    }
}

Final Architecture Summary

LayerResponsibility
ModelsRaw DB structure
DTOsAPI decoding
ServicesAPIs/Network calls
ViewModelsBusiness/UI logic
ViewsUI

Why This Architecture Works?

  • Scalability – Easily add more features without breaking existing code
  • Separation of Concerns – Each layer has a single responsibility
  • Maintainability – Debugging becomes straightforward
  • Supabase-Friendly – Handles nested relational data cleanly
  • Decoupling – UI is independent of API structure

Take Away

To wrap up, a clean and scalable architecture depends on a few simple but important practices. Model many-to-many relationships using proper junction tables, and handle Supabase responses through DTOs that can be safely transformed into clean UI models. Keep all Supabase-related logic inside a dedicated service layer to maintain structure and testability, and ensure your views never interact directly with APIs. This separation keeps your codebase modular, maintainable, and easier to evolve over tim