Triton/Documentation/ADR/ADR-010-appfactory-pattern-for-feature-integration.md
Otávio 3e878667a1 Add Triton App
Signed-off-by: Otavio Cordeiro <otaviocc@users.noreply.github.com>
2025-12-15 20:39:07 +01:00

5.3 KiB

ADR-010: AppFactory Pattern for Feature Integration

Status: Accepted

Date: 2025-01-11

Context:

With a modular architecture where each feature is a separate package, I needed a consistent way to integrate features into the main application. The key challenges were:

  1. Encapsulation: Feature internals (view models, repositories, services) should remain private
  2. Dependency injection: Features need various dependencies from the main app
  3. Multiple entry points: Features may provide main views, scenes, and settings views
  4. SwiftUI integration: Need to provide both views and scenes (WindowGroup, etc.)
  5. Testability: Integration points should be mockable

Decision:

I adopted the AppFactory pattern where each feature package exposes a single public factory class with three standardized methods:

  1. makeAppView() - Creates the main feature view for display in the sidebar or main content area
  2. makeScene() - Returns window scenes (WindowGroup) private to the module
  3. makeSettingsView() - Creates settings views when applicable (optional)

Factories receive dependencies via their initializer and internally manage a feature-specific Environment that handles further dependency resolution.

Implementation Pattern:

public final class StatusAppFactory {
    private let environment: StatusEnvironment

    public init(
        sessionService: any SessionServiceProtocol,
        authSessionService: any AuthSessionServiceProtocol,
        networkClient: NetworkClientProtocol
    ) {
        environment = .init(
            sessionService: sessionService,
            authSessionService: authSessionService,
            networkClient: networkClient
        )
    }

    @MainActor
    @ViewBuilder
    public func makeAppView() -> some View {
        let viewModel = environment.viewModelFactory
            .makeStatusAppViewModel()

        StatusApp(viewModel: viewModel)
            .environment(\.viewModelFactory, environment.viewModelFactory)
            .modelContainer(environment.modelContainer)
    }

    @MainActor
    @ViewBuilder
    public func makeSettingsView() -> some View {
        let viewModel = environment.viewModelFactory
            .makeStatusSettingsViewModel()

        StatusSettingsView(viewModel: viewModel)
            .modelContainer(environment.modelContainer)
    }

    @MainActor
    public func makeScene() -> some Scene {
        ComposeStatusScene(environment: environment)
    }
}

Integration with TritonEnvironment:

struct TritonEnvironment: TritonEnvironmentProtocol {
    private let container = DependencyContainer()

    var statusAppFactory: StatusAppFactory { container.resolve() }

    init() {
        container.register(
            type: StatusAppFactory.self,
            allocation: .static
        ) { container in
            StatusAppFactory(
                sessionService: container.resolve(),
                authSessionService: container.resolve(),
                networkClient: container.resolve()
            )
        }
    }
}

Why Factories Over Direct Instantiation:

  1. Encapsulation: Keeps view models, repositories, and internal dependencies private
  2. Single public interface: Only the factory is public, everything else is internal
  3. Dependency coordination: Factory manages complex dependency graphs internally
  4. Consistent integration: All features integrate the same way
  5. Testability: Can mock factories for testing main app integration
  6. Lazy initialization: Features only initialize when actually used

Consequences:

Positive

  • Clear boundaries: Features expose minimal public API surface
  • Consistent pattern: All features integrate identically
  • Dependency management: Complex graphs handled internally by each feature
  • Flexibility: Features can evolve internals without affecting main app
  • Testability: Integration points are mockable
  • SwiftUI native: Returns proper SwiftUI types (some View, some Scene)

Negative

  • Boilerplate: Each feature needs a factory class
  • Indirect access: Can't directly instantiate feature components
  • Factory proliferation: One factory per feature adds code

Neutral

  • Learning curve: Team members need to understand factory pattern
  • Factory responsibilities: Ongoing decisions about what belongs in factory vs environment

Method Contracts:

  • makeAppView() - REQUIRED: Must return main feature view, marked @MainActor
  • makeScene() - OPTIONAL: Returns scenes for auxiliary windows, marked @MainActor
  • makeSettingsView() - OPTIONAL: Returns settings view when feature has preferences, marked @MainActor

Related Decisions:

Notes:

The AppFactory pattern provides a clean, consistent way to integrate modular features while maintaining strong encapsulation. It's the primary public interface for each feature package.