DEV Community

Takuto NAKAMURA (Kyome)
Takuto NAKAMURA (Kyome)

Posted on

LUCA: A Modern Architecture for SwiftUI Development

Prerequisites

Background: Why a New Architecture is Needed

When developing new apps with SwiftUI, TCA (The Composable Architecture) is likely the first architecture that comes to mind. Having used TCA in production for over a year, I've experienced its benefits in terms of consistent implementation and testability. However, TCA can become a constraint during certain phases of app development. During the initial app launch phase when concepts aren't yet solidified, rapid iteration of creation and destruction is essential, making speed and flexibility more important than robust implementations with comprehensive tests. While TCA is convenient, its strong writing constraints can turn what would be a few minutes of pure Swift implementation into hours of struggle. (I've even reached out to Point-Free about this.) TCA is an excellent architecture when you're on track and requirements don't fundamentally change, but we determined it wasn't suitable for our current needs.

Additionally, while this isn't directly TCA's fault, the current Xcode specifications make it difficult to handle this library properly. The compiler fails to display errors correctly, and we're plagued with numerous warnings about missing test code, resulting in an unfortunately poor development experience. However, TCA's writing style and testability are so appealing that I didn't want to abandon them.

Thus, we needed a pure Swift/SwiftUI architecture that would restore flexible and fast development experience by moving away from TCA while retaining TCA's appeal.

Design Concepts

Characteristic Requirements
Maintainability Improve maintainability through clear separation of concerns and consistent patterns
Testability Easy dependency injection and component-by-component testing through Unit Tests
Scalability Improve extensibility through hierarchical state management and easy addition of new features
Type Safety Type-safe Actions and Navigation
SwiftUI Integration Ensure compatibility with SwiftUI

Requirements

  • Not unnecessarily complex, suitable for personal development and app launch phases
  • Implementable using only Apple's native frameworks and swiftlang/apple OSS libraries
  • Xcode 16.4+, Swift 6.1+, iOS 17+, macOS 14+

LUCA

LUCA is a practical architecture optimized for the SwiftUI × Observation era.

L Layered Clear 3-layer separation of concerns
U Unidirectional Data Flow Single-direction data flow
C Composable Composability and testability like TCA
A Architecture Architecture

In other words, it's a design that maintains unidirectional data flow, performs hierarchical state management, and is both extensible and easily testable. Strongly influenced by TCA, it's implemented with a focus on being lightweight and compilable using pure Swift language features while maximizing compatibility with SwiftUI APIs.

As AI puts it:

"Clean layers, Clear flow, Composable design"

Overall Architecture

┌───────────────────┐
│   UserInterface   │  ← UI provision and event handling
├───────────────────┤
│       Model       │  ← Business logic and state management
├───────────────────┤
│    DataSource     │  ← Data access and infrastructure abstraction
└───────────────────┘
Enter fullscreen mode Exit fullscreen mode

Composed of three layers: DataSource, Model, and UserInterface, each layer is provided as a module in a local package (multi-module configuration using Swift Package).

For implementation and maintenance ease, the architecture doesn't require special frameworks in principle (meaning it can be implemented using just Swift language features). However, for testability, dependency injection is made possible using SwiftUI's EnvironmentValues.

File Structure:

.
├── LocalPackage
│   ├── Package.swift
│   ├── Sources
│   │   ├── DataSource
│   │   │   ├── Dependencies
│   │   │   │   └── AppStateClient.swift
│   │   │   ├── Entities
│   │   │   │   └── AppState.swift
│   │   │   ├── Extensions
│   │   │   ├── Repositories
│   │   │   └── DependencyClient.swift
│   │   ├── Model
│   │   │   ├── Extensions
│   │   │   ├── Services
│   │   │   ├── Stores
│   │   │   ├── AppDelegate.swift (optional)
│   │   │   ├── AppDependencies.swift
│   │   │   └── Composable.swift
│   │   └── UserInterface
│   │       ├── Extensions
│   │       ├── Resources
│   │       ├── Scenes
│   │       └── Views
│   └── Tests
│       └── ModelTests
│           ├── ServiceTests
│           └── StoreTests
├── ProjectName
│   └── ProjectNameApp.swift
└── ProjectName.xcodeproj
Enter fullscreen mode Exit fullscreen mode

Role of Each Layer

UserInterface (formerly Presentation)

Handles UI provision and event handling.

Directory Roles
Extensions • Implementation of extensions
Resources • Provision of resources like String/Asset Catalog
Scenes • Provision of Scenes used in the app
Views • Provision of Views used in Scenes

Image and text resources are placed only here. Therefore, when resources are needed in the Model layer, techniques to avoid direct usage are necessary. For example, the Model layer only handles String.LocalizationValue, while the UserInterface layer handles the actual resources. Also, when handling images or text from enum or struct defined in the DataSource layer, extensions are added to access resources.

Model (formerly Domain)

Handles business logic and state management.

Directory/Special File Roles
Extensions • Implementation of extensions
Services • Process and manipulate data using Dependencies and Repositories
Stores • Implementation of business logic
• Preparation of data to display in Views
• Handling events and user actions
AppDelegate.swift (optional) • Trigger for app lifecycle events
AppDependencies.swift • Singleton management of Dependencies and provision of access methods to Views
Composable.swift • Protocol for Stores

Tests involve writing Unit Tests for Services and Stores. If LUCA is implemented well, Unit Tests alone can provide substantial coverage of app functionality.

DataSource (formerly DataLayer)

Handles data access and infrastructure abstraction.

Directory/Special File Roles
Dependencies • Indirectly provide APIs containing side effects outside our control
• Make them replaceable during dependency injection in Services and Stores
Entities • Definition of data types to handle
Extensions • Implementation of extensions
Repositories • Handle data reading and writing
AppStateClient.swift • Special DependencyClient for AppState
AppState.swift • Management of state needed throughout the app lifecycle
• Provision of Streams for state update propagation
DependencyClient.swift • Protocol for Dependencies

Since Repositories often handle fixed keys, it's good to extend String in Extensions to make keys type-safe.


What are APIs containing side effects outside our control?

  • FileManager.default.moveItem(at:to:)
  • UserDefaults.standard.set(_:forKey:)
  • NSApplication.shared.terminate(_:)
  • NSWorkspace.shared.open(_:)

These are APIs like file I/O or calls to other processes. We consider the actual behavior guarantee to be outside the app's jurisdiction. Therefore, third-party library APIs may also fall under this criterion depending on the case.


Implementation Examples

Specific Implementation Methods

I'll introduce LUCA's implementation methods using a simple BMI calculation app as an example.

Configure Local Package with Swift Package Manager

It's convenient to save the template as a snippet.

Package.swift

// swift-tools-version: 6.1

import PackageDescription

let swiftSettings: [SwiftSetting] = [
    .enableUpcomingFeature("ExistentialAny"),
]

let package = Package(
    name: "LocalPackage",
    defaultLocalization: "en",
    platforms: [
        .iOS(.v18),
    ],
    products: [
        .library(
            name: "DataSource",
            targets: ["DataSource"]
        ),
        .library(
            name: "Model",
            targets: ["Model"]
        ),
        .library(
            name: "UserInterface",
            targets: ["UserInterface"]
        ),
    ],
    dependencies: [],
    targets: [
        .target(
            name: "DataSource",
            swiftSettings: swiftSettings
        ),
        .target(
            name: "Model",
            dependencies: [
                "DataSource",
            ],
            swiftSettings: swiftSettings
        ),
        .target(
            name: "UserInterface",
            dependencies: [
                "Model",
            ],
            resources: [.process("Resources")],
            swiftSettings: swiftSettings
        ),
        .testTarget(
            name: "ModelTests",
            dependencies: [
                "Model",
            ],
            swiftSettings: swiftSettings
        ),
    ]
)
Enter fullscreen mode Exit fullscreen mode

DataSource Layer Implementation

1. General Entity Implementation

Define data structures using struct or enum. Make them conform to Identifiable, Hashable, Codable, etc., as needed.

LocalPackage/Sources/DataSource/Entities/Person.swift

import Foundation

public struct Person: Codable, Sendable, Equatable {
    public var name: String
    public var weight: Double // kg
    public var height: Double // cm

    public init(name: String, weight: Double, height: Double) {
        self.name = name
        self.weight = weight
        self.height = height
    }

    public static let empty = Person(name: "", weight: .zero, height: .zero)
}
Enter fullscreen mode Exit fullscreen mode

Coding Rules

  • Don't write business logic in Entities

2. DependencyClient.swift Implementation

Define the protocol that all Dependencies should conform to. Also define convenience functions for easy test implementation.

LocalPackage/Sources/DataSource/DependencyClient.swift

public protocol DependencyClient: Sendable {
    static var liveValue: Self { get }
    static var testValue: Self { get }
}

public func testDependency<D: DependencyClient>(of type: D.Type, injection: (inout D) -> Void) -> D {
    var dependencyClient = type.testValue
    injection(&dependencyClient)
    return dependencyClient
}
Enter fullscreen mode Exit fullscreen mode

3. General Dependency Implementation

Abstract access to APIs containing side effects outside our control and make them mockable during testing.

LocalPackage/Sources/DataSource/Dependencies/UserDefaultsClient.swift

import Foundation

public struct UserDefaultsClient: DependencyClient {
    var data: @Sendable (String) -> Data?
    var setData: @Sendable (Data?, String) -> Void

    public static let liveValue = Self(
        data: { UserDefaults.standard.data(forKey: $0) },
        setData: { UserDefaults.standard.set($0, forKey: $1) }
    )

    public static let testValue = Self(
        data: { _ in nil },
        setData: { _, _ in }
    )
}
Enter fullscreen mode Exit fullscreen mode

Coding Rules

  • In principle, provide the original API interface as-is
    • Don't change property names or function names unnecessarily
    • Don't reduce arguments using default values just because they're unnecessary
  • When the original has properties or functions on instances, receive the instance as the first argument
  public struct DataClient: DependencyClient {
      public var write: @Sendable (Data, URL) throws -> Void

      public static let liveValue = Self(
          write: { try $0.write(to: $1) }
      )

      public static let testValue = Self(
          write: { _, _ in }
      )
  }
Enter fullscreen mode Exit fullscreen mode

4. General Repository Implementation

Encapsulate direct data reading and writing in Repositories. Only perform data I/O through Repositories. This is what contributes to testability.

LocalPackage/Sources/DataSource/Repositories/UserDefaultsRepository.swift

import Foundation

public struct UserDefaultsRepository: Sendable {
    private var userDefaultsClient: UserDefaultsClient

    public var person: Person {
        get {
            guard let data = userDefaultsClient.data(.person) else { return Person.empty }
            return (try? JSONDecoder().decode(Person.self, from: data)) ?? Person.empty
        }
        nonmutating set {
            let data = try? JSONEncoder().encode(newValue)
            userDefaultsClient.setData(data, .person)
        }
    }

    public init(_ userDefaultsClient: UserDefaultsClient) {
        self.userDefaultsClient = userDefaultsClient
    }
}
Enter fullscreen mode Exit fullscreen mode

Make string keys type-safe when handling them. (Of course, methods other than String extensions are also fine.)

LocalPackage/Sources/DataSource/Extensions/String+Extension.swift

extension String {
    static let person = "person"
}
Enter fullscreen mode Exit fullscreen mode

Coding Rules

  • Pass necessary Dependencies as init arguments

5. AppState.swift Implementation

Implement AppState, a special Entity that manages state shared across the entire app.

LocalPackage/Sources/DataSource/Entities/AppState.swift

import Combine

public struct AppState: Sendable {
    public var hasAlreadyTutorial: Bool = false
}
Enter fullscreen mode Exit fullscreen mode

6. AppStateClient.swift Implementation

Implement AppStateClient, a special Dependency that provides safe access methods to AppState.

LocalPackage/Sources/DataSource/Dependencies/AppStateClient.swift

import os

public struct AppStateClient: DependencyClient {
    var getAppState: @Sendable () -> AppState
    var setAppState: @Sendable (AppState) -> Void

    public func withLock<R: Sendable>(_ body: @Sendable (inout AppState) throws -> R) rethrows -> R {
        var state = getAppState()
        let result = try body(&state)
        setAppState(state)
        return result
    }

    public static let liveValue: Self = {
        let state = OSAllocatedUnfairLock<AppState>(initialState: .init())
        return Self(
            getAppState: { state.withLock(\.self) },
            setAppState: { value in state.withLock { $0 = value } }
        )
    }()

    public static let testValue = Self(
        getAppState: { .init() },
        setAppState: { _ in }
    )
}
Enter fullscreen mode Exit fullscreen mode

Model Layer Implementation

1. AppDependencies.swift Implementation

Implement AppDependencies that aggregates all dependencies and provides them as environment variables.

LocalPackage/Sources/Model/AppDependencies.swift

import DataSource
import SwiftUI

public struct AppDependencies: Sendable {
    public var appStateClient = AppStateClient.liveValue
    public var userDefaultsClient = UserDefaultsClient.liveValue

    // Implement when AppDelegate is needed
    static let shared = AppDependencies()
}

extension EnvironmentValues {
    @Entry public var appDependencies = AppDependencies.shared
}

// Define convenience functions to make test implementation easier
extension AppDependencies {
    public static func testDependencies(
        appStateClient: AppStateClient = .testValue,
        userDefaultsClient: UserDefaultsClient = .testValue
    ) -> AppDependencies {
        AppDependencies(
            appStateClient: appStateClient,
            userDefaultsClient: userDefaultsClient
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

2. AppDelegate.swift Implementation (Optional)

Implement when app lifecycle events are needed.

LocalPackage/Sources/Model/AppDelegate.swift

import DataSource
import SwiftUI

@MainActor public final class AppDelegate: NSObject, NSApplicationDelegate {
    private let appDependencies = AppDependencies.shared

    public func applicationDidFinishLaunching(_ notification: Notification) {
        // Do what you want to do here
        // Example: log system initialization, setting loading, etc.
    }

    public func applicationWillTerminate(_ notification: Notification) {
        // App termination processing
    }
}
Enter fullscreen mode Exit fullscreen mode

3. General Service Implementation

Implement Services that provide business logic. Services themselves don't hold state, so when state is needed, provide it as arguments or obtain it via AppStateClient.

LocalPackage/Sources/Model/Services/BMIService.swift

import DataSource

public struct BMIService {
    private let appStateClient: AppStateClient

    public init(_ appDependencies: AppDependencies) {
        self.appStateClient = appDependencies.appStateClient
    }

    public func calculateBMI(weight: Double, height: Double) -> Double {
        guard height > 0 else { return 0 }
        let heightInMeters = height / 100
        return (100 * weight / (heightInMeters * heightInMeters)).rounded() / 100
    }
}
Enter fullscreen mode Exit fullscreen mode

Coding Rules

  • Make init arguments AppDependencies instead of direct Dependencies
  • When Dependencies or Repositories are needed, construct them from AppDependencies
  • Services are basically defined as struct, and as actor when necessary

4. Composable.swift Implementation

Define the protocol that all Stores should conform to.

LocalPackage/Sources/Model/Composable.swift

import Observation

@MainActor
public protocol Composable: AnyObject {
    associatedtype Action: Sendable

    var action: (Action) async -> Void { get }

    func reduce(_ action: Action) async
}

public extension Composable {
    func reduce(_ action: Action) async {}

    func send(_ action: Action) async {
        await self.action(action)
        await reduce(action)
    }
}
Enter fullscreen mode Exit fullscreen mode

5. General Store Implementation

Implement Stores that handle screen state management and event handling.

LocalPackage/Sources/Model/Stores/PersonBMI.swift

import Foundation
import DataSource
import Observation

@MainActor @Observable public final class PersonBMI: Composable {
    private let userDefaultsRepository: UserDefaultsRepository
    private let bmiService: BMIService
    private let appStateClient: AppStateClient

    public var person: Person
    public var calculatedBMI: Double
    public var isPresentedTutorial: Bool
    public let action: (Action) async -> Void

    public init(
        _ appDependencies: AppDependencies,
        person: Person = .empty,
        calculatedBMI: Double = .zero,
        isPresentedTutorial: Bool = false,
        action: @escaping (Action) async -> Void
    ) {
        self.userDefaultsRepository = .init(appDependencies.userDefaultsClient)
        self.bmiService = .init(appDependencies)
        self.appStateClient = appDependencies.appStateClient
        self.person = person
        self.calculatedBMI = calculatedBMI
        self.isPresentedTutorial = isPresentedTutorial
        self.action = action
    }

    public func reduce(_ action: Action) async {
        switch action {
        case .task:
            person = userDefaultsRepository.person
            calculatedBMI = bmiService.calculateBMI(weight: person.weight, height: person.height)
            isPresentedTutorial = appStateClient.withLock {
                if $0.hasAlreadyTutorial {
                    return false
                } else {
                    $0.hasAlreadyTutorial = true
                    return true
                }
            }

        case .calculateButtonTapped:
            calculatedBMI = bmiService.calculateBMI(weight: person.weight, height: person.height)

        case .saveButtonTapped:
            userDefaultsRepository.person = person
        }
    }

    public enum Action {
        case task
        case calculateButtonTapped
        case saveButtonTapped
    }
}
Enter fullscreen mode Exit fullscreen mode

Coding Rules

  • Make all properties passable as init arguments (similar to struct's memberwise initializer)
  • When you want to pass default values to properties, receive default arguments in init rather than defining them at definition time
  • Action case naming conventions

    • Use SwiftUI event names basically as-is
    case task
    case onDisappear
    case onTapGesture
    case onChangeSomeValue // For onChange, add what changed to the end for clarity
    
    • For user action-based cases, use unified naming patterns by UI component
    • Button: 〜ButtonTapped
      case cancelButtonTapped
      case createImageButtonTapped
      case deleteButtonTapped
    
    • Toggle: 〜ToggleSwitched
      case notificationsToggleSwitched(Bool)
      case darkModeToggleSwitched(Bool)
    
    • Picker: 〜PickerSelected
      case themePickerSelected(Theme)
      case languagePickerSelected(Language)
    
    • For cases involving communication, use naming like ~Response and receive Result
    case submitRecordResponse(Result<String, any Error>)
    case fetchImageResponse(Result<UIImage, any Error>)
    

UserInterface Layer Implementation

1. General View Implementation

Implement as a regular SwiftUI View. Focus on having a corresponding Store and reflecting the data held by that Store.

LocalPackage/Sources/UserInterface/Views/PersonBMIView.swift

import DataSource
import Model
import SwiftUI

struct PersonBMIView: View {
    @State var store: PersonBMI

    var body: some View {
        Form {
            Section {
                LabeledContent("Name") {
                    TextField("Enter name", text: $store.person.name)
                        .textFieldStyle(.roundedBorder)
                }
                LabeledContent("Weight (kg)") {
                    TextField("Weight", value: $store.person.weight, format: .number)
                        .textFieldStyle(.roundedBorder)
                }
                LabeledContent("Height (cm)") {
                    TextField("Height", value: $store.person.height, format: .number)
                        .textFieldStyle(.roundedBorder)
                }
            }
            Section {
                LabeledContent("BMI") {
                    Text(String(format: "%.1f", store.calculatedBMI))
                }
                Button("Calculate BMI") {
                    Task {
                        await store.send(.calculateButtonTapped)
                    }
                }
                .buttonStyle(.borderedProminent)
                Button("Save") {
                    Task {
                        await store.send(.saveButtonTapped)
                    }
                }
                .buttonStyle(.bordered)
            }
        }
        .task {
            await store.send(.task)
        }
        .alert("Tutorial", isPresented: $store.isPresentedTutorial) {
            Button("OK") {}
        }
    }
}

#Preview {
    PersonBMIView(store: .init(.testDependencies()))
}
Enter fullscreen mode Exit fullscreen mode

Coding Rules

  • Define Store as store
    • Also name it store when getting it in ForEach, etc.
  • Use store.send(Action) to communicate events to Store
    • Use Unstructured Task in Button, etc., since await is required
  • Write Preview macro so Xcode Preview works

2. General Scene Implementation

Define a Scene that displays the defined View. Since Store basically needs AppDependencies, obtain it via Environment.

LocalPackage/Sources/UserInterface/Scenes/PersonBMIScene.swift

import Model
import SwiftUI

public struct PersonBMIScene: Scene {
    @Environment(\.appDependencies) private var appDependencies

    public init() {}

    public var body: some Scene {
        WindowGroup {
            NavigationView {
                PersonBMIView(store: .init(appDependencies))
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

App Implementation

Implement the application entry point. In other words, do nothing more than that. Keep it contained within the Local Package.

BMI/BMIApp.swift

import UserInterface
import SwiftUI

@main
struct BMIApp: App {
    // Define when AppDelegate is needed
    @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate

    var body: some Scene {
        PersonBMIScene()
    }
}
Enter fullscreen mode Exit fullscreen mode

Test Implementation

In LUCA, you can test the main app functionality by writing unit tests for Services and Stores.

General Service Tests

Stateless Services can be easily tested as pure functions.

LocalPackage/Tests/ModelTests/ServiceTests/BMIServiceTests.swift

import Testing
import DataSource
@testable import Model

@MainActor struct BMIServiceTests {
    @Test
    func calculateBMI_HeightNotZero_ReturnsValidValue() {
        let sut = BMIService(.testDependencies())
        let actual = sut.calculateBMI(weight: 70, height: 175)
        #expect(actual == 22.86)
    }

    @Test
    func calculateBMI_HeightIsZero_ReturnsZero() {
        let sut = BMIService(.testDependencies())
        let actual = sut.calculateBMI(weight: 70, height: 0)
        #expect(actual == 0)
    }
}
Enter fullscreen mode Exit fullscreen mode

General Store Tests

Test Stores by mocking AppDependencies.

LocalPackage/Tests/ModelTests/StoreTests/PersonBMITests.swift

import os
import Testing
@testable import DataSource
@testable import Model

struct PersonTests {
    @MainActor @Test
    func send_task_SavedDataIsRestored() async {
        let sut = PersonBMI(.testDependencies(
            userDefaultsClient: testDependency(of: UserDefaultsClient.self) {
                $0.data = { _ in
                    return try! JSONEncoder().encode(Person(name: "Test", weight: 70.0, height: 175.0))
                }
            }
        ))
        await sut.send(.task)
        #expect(sut.person == Person(name: "Test", weight: 70.0, height: 175.0))
    }

    @MainActor @Test
    func send_calculateButtonTapped_BMIIsCalculated() async {
        let sut = PersonBMI(
            .testDependencies(),
            person: Person(name: "Test", weight: 70.0, height: 175.0)
        )
        await sut.send(.calculateButtonTapped)
        #expect(sut.calculatedBMI == 22.86)
    }

    @MainActor @Test
    func send_saveButtonTapped_DataIsSaved() async {
        var savedData = OSAllocatedUnfairLock<Data?>(initialState: nil)
        let sut = PersonBMI(
            .testDependencies(
                userDefaultsClient: testDependency(of: UserDefaultsClient.self) {
                    $0.setData = { data, _ in
                        savedData.withLock { $0 = data }
                    }
                }
            ),
            person: Person(name: "Test", weight: 70.0, height: 175.0)
        )
        await sut.send(.saveButtonTapped)
        #expect(savedData.withLock(\.self) != nil)
    }
}
Enter fullscreen mode Exit fullscreen mode

This way, dependency injection allows independent testing of each component, which is a major advantage of LUCA.

Coding Rules

  • Test case names should be send_{ActionName}_{condition(optional)}_{expectedResult}
  func send_task_LogIsOutput()
  func send_deleteButtonTapped_ImageIsSelected_ImageIsDeleted()
  func send_notificationsToggleSwitched_NotificationSettingIsDisabled_NotificationSettingIsEnabled()
  func send_themePickerSelected_ThemeIsChanged()
Enter fullscreen mode Exit fullscreen mode

Advanced Implementation

Handling Child Events in Parent

When you want to handle child Store events in parent Store, delegate using Action closures.

Child Store Implementation:

@MainActor @Observable public final class Child: Composable {
    public let action: (Action) -> Void

    public init(
        action: @escaping (Action) -> Void
    ) {
        self.action = action
    }

    public func reduce(_ action: Action) async {
        switch action {
        case .closeButtonTapped:
            break
        }
    }

    public enum Action {
        case closeButtonTapped
    }
}
Enter fullscreen mode Exit fullscreen mode

Parent Store Implementation:

@MainActor @Observable public final class Parent: Composable {
    public var child: Child?
    public let action: (Action) -> Void

    public init(
        child: Child? = nil,
        action: @escaping (Action) async -> Void = { _ in }
    ) {
        self.child = child
        self.action = action
    }

    public func reduce(_ action: Action) async {
        switch action {
        case .openChildButtonTapped:
            child = .init(action: { [weak self] in
                self?.send(.child($0))
            })

        // Handle child's Action
        case .child(.closeButtonTapped):
            child = nil

        case .child:
            break
        }
    }

    public enum Action {
        case openChildButtonTapped
        case child(Child.Action)
    }
}
Enter fullscreen mode Exit fullscreen mode

Passing AppDependencies to Child

When child initialization needs AppDependencies, pass the value from View's EnvironmentValues through Action.

Parent Store Implementation:

@MainActor @Observable public final class Parent: Composable {
    public var child: Child?
    public let action: (Action) -> Void

    public init(
        child: Child? = nil,
        action: @escaping (Action) async -> Void = { _ in }
    ) {
        self.child = child
        self.action = action
    }

    public func reduce(_ action: Action) async {
        switch action {
        case let .openChildButtonTapped(appDependencies):
            child = .init(appDependencies, action: { [weak self] in
                self?.send(.child($0))
            })

        case .child:
            break
        }
    }

    public enum Action {
        case openChildButtonTapped(AppDependencies) // Receive via View
        case child(Child.Action)
    }
}
Enter fullscreen mode Exit fullscreen mode

Navigation

Implement type-safe navigation management using NavigationStack.

Store Implementation with Path Definition:

@MainActor @Observable public final class Fruits: Composable {
    public var path: [Path]
    public var bananas: [Banana]
    public let action: (Action) async -> Void

    public init(
        _ appDependencies: AppDependencies,
        path: [Path] = [],
        bananas: [Banana] = [],
        action: @escaping (Action) async -> Void
    ) { /* omitted */ }

    public func reduce(_ action: Action) async {
        switch action {
        case let .appleButtonTapped(appDependencies):
            path.append(.apple(.init(appDependencies, action: { [weak self] in
                self?.send(.settings($0))
            })))

        case let .bananaButtonTapped(store):
            path.append(.banana(store))

        case .apple:
            break

        case .banana:
            break
        }
    }

    public enum Action {
        case appleButtonTapped(AppDependencies) // Pattern of creating new Store
        case bananaButtonTapped(Banana) // Pattern of receiving already created Store
        case apple(Apple.Action)
        case banana(Banana.Action)
    }

    public enum Path: Hashable {
        case apple(Apple)
        case banana(Banana)

        public static func ==(lhs: Path, rhs: Path) -> Bool {
            lhs.id == rhs.id
        }

        public func hash(into hasher: inout Hasher) {
            hasher.combine(id)
        }

        // Destination Store must conform to Identifiable
        var id: Int {
            switch self {
            case let .apple(value):
                Int(bitPattern: ObjectIdentifier(value))
            case let .banana(value):
                Int(bitPattern: ObjectIdentifier(value))
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

View Implementation using NavigationStack and navigationDestination:

struct FruitsView: View {
    @Environment(\.appDependencies) private var appDependencies
    @State var store: Fruits

    var body: some View {
        NavigationStack(path: $store.path) {
            VStack {
                Button("Apple") {
                    Task {
                        await store.send(.appleButtonTapped(appDependencies))
                    }
                }
                ForEach(store.bananas) { store in
                    Button("Banana: \(store.id)") {
                        Task {
                            await store.send(.bananaButtonTapped(store))
                        }
                    }
                }
            }
            .navigationDestination(for: Fruits.Path.self) { path in
                switch path {
                case let .apple(store):
                    AppleView(store: store)

                case let .banana(store):
                    BananaView(store: store)
                }
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Summary

LUCA is a practical architecture optimized for the SwiftUI × Observation era. It uses only Apple's native frameworks and achieves a lightweight design suitable for personal development.

LUCA Characteristics

Characteristic Description
Maintainability Improved maintainability through clear separation of concerns
Clear role of each layer, limiting scope of change impact
Testability Easy Unit Tests through dependency injection
Comprehensive app functionality coverage through Service and Store tests
Scalability Ensured extensibility through hierarchical state management
Minimize impact on existing code when adding new features
Type Safety Type-safe state management through Action-centered unified event processing
Consistency Improved development efficiency through consistent patterns
Maintain code uniformity even in team development
SwiftUI Integration Maximize compatibility with SwiftUI APIs and leverage framework characteristics

Action-centered unified event processing, stateless Services, and centralized state management via AppStateClient achieve improved traceability and clarified data flow.

I hope this architecture can contribute to solving challenges in SwiftUI app development and help achieve better application development.

Top comments (0)