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
| Layer | Responsibility |
|---|---|
| Models | Raw DB structure |
| DTOs | API decoding |
| Services | APIs/Network calls |
| ViewModels | Business/UI logic |
| Views | UI |
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