Protocol design with internal storage requirements β€” usable from external modules?

Hi,

I have a question about protocol design and access control in Swift, especially regarding usage from external modules.

I want to design a protocol where:

  • a method is public

  • some properties required for implementation are NOT public (internal only)

Here is a simplified example:

public protocol VCInjectable: ViewController {
    associatedtype VM: ViewModel
    associatedtype UI: UserInterface

    var viewModel: VM! { get set }
    var ui: UI! { get set }

    func inject(viewModel: VM, ui: UI)
}

As expected, since the protocol is public, all requirements must also be public, which exposes implementation details that I would prefer to keep internal.


Workaround

I split the protocol into public API and internal storage:

internal protocol _VCInjectableStorage: ViewController {
    associatedtype VM: ViewModel
    associatedtype UI: UserInterface

    var viewModel: VM! { get set }
    var ui: UI! { get set }
}

public protocol VCInjectable: ViewController {
    associatedtype VM: ViewModel
    associatedtype UI: UserInterface

    func inject(viewModel: VM, ui: UI)
}

extension VCInjectable where Self: _VCInjectableStorage {
    public func inject(viewModel: VM, ui: UI) {
        self.viewModel = viewModel
        self.ui = ui
    }
}


My concern

With this design, _VCInjectableStorage is internal, so:

  • Types in the same module can conform to both protocols and use the default implementation.

  • But types in external modules cannot see or conform to _VCInjectableStorage.

This means:

  • They can conform to VCInjectable

  • But they cannot use the default inject implementation


Questions

  1. Is there a better pattern if I want:

    • to keep storage requirements internal

    • while still allowing external modules to use the default implementation?


Goal

  • Hide internal state (viewModel, ui)

  • Expose only inject as public API


Thanks!

Hello!

A protocol requirement can’t be treated as an implementation detail. A type that conforms a protocol (possibly external, if the protocol is public) must be able to see all requirements in order to satisfy them.

Your workaround illustrates the issue well. Look: to implement inject(viewModel:ui:), a type must have settable viewModel and ui properties. But _VCInjectableStorage is internal (as those hypothetical 'internal requirements' would be), so an external type has no way to know it must implement such properties.

// External module

struct ViewModel: Library.ViewModel { }
struct UserInterface: Library.UserInterface { }

struct Module: VCInjectable {  // Under your idea, this should compile, since `inject(viewModel:ui:)` has a default implementation in the library
    typealias VM = ViewModel
    typealias UI = UserInterface
}

// Library

extension VCInjectable where Self: _VCInjectableStorage {
    mutating func inject(viewModel: VM, ui: UI) {
        self.viewModel = viewModel // Oops! Implementation has no `viewModel` property!
        self.ui = ui
    }
}

That said, you can add internal functionality that relies only on the public requirements of a public protocol, for example, via protocol extensions:

// Library

public protocol LoggingConvertible {
    var loggingDescription: String { get }
}

internal extension LoggingConvertible {
    func log() { // Not accessible outside the module
        print(loggingDescription)
    }
}

public func libraryLog(_ value: some LoggingConvertible) {
    value.log() // Internal usage
}

// External Module

struct Entity: LoggingConvertible {
    private let id: UUID = .init()
    
    var loggingDescription: String { "Entity \(id)" }
}

let entity = Entity()
libraryLog(entity)

If you specifically want a design with hidden storage and a public interface, classes are the right tool:

// Library

open class InjectableBase<VM: ViewModel, UI: UserInterface> {
    internal var viewModel: VM!
    internal var ui: UI!
    
    public func inject(viewModel: VM, ui: UI) {
        self.viewModel = viewModel
        self.ui = ui
    }
}
1 Like

Thanks a lot for the explanation!
This clarified the constraints around access control and protocol design for me.
Really appreciate your help :folded_hands:

You could split this your protocol into two: one public, one internal which inherits the public one. In your case, you can have VCInjectable: ViewController, _VCInjectableStorage.

For instance, in the following examaple, the Pub protocol is exposed to public, and the Internal protocol defines internal used properties but also inherits all the funcs and properties.

public protocol Pub {
    func publicFunc()
}

internal protocol Internal: Pub {
    func internalFunc()
}

public struct Test: Internal {
    func internalFunc() {
        // stay internal
    }
    
    public func publicFunc() { // removing pub will be an error
        
    }
}

func operation(_ value: some Internal) {
    value.publicFunc()
    value.internalFunc()
}

This solution is not perfect. In some cases, you could run into compiler error "Conditional conformance of type to protocol does not imply conformance to inherited protocol", but I think it should be fine in your use case.